Fuzzing van de Gleam-compiler

Inleiding

Ik volg regelmatig de changelog en de issue tracker van Gleam. Ik ben erg gesteld op dit project en de mensen die eraan bijdragen. Maar elke keer als ik een issue zie dat betrekking heeft op codegeneratie of verschillen in output tussen Erlang en JavaScript, knaagt het aan me dat er geen manier was om in feite "alle Gleam-programma's te berekenen", ze uit te voeren en te kijken of er problemen zijn.

Ik stel het me voor als een schaakbord, waar je een quasi-oneindig aantal mogelijke posities op het bord hebt. Maar we willen dat het schaakbord Gleam-programma's bevat en we willen een oneindig grote database van die programma's om te zien of ze ongeteste edge cases blootleggen.

Mijn eerste poging om iets in die richting te doen, was het aansturen van een LLM. Ik instrueerde het model om grote hoeveelheden oude Gleam-issues door te lezen en meer edge cases te vinden door er "hard over na te denken". Het kwam met allerlei combinaties van bit arrays, geneste anonieme functies en geneste use-patronen. Voorspelbaar leverde deze aanpak niet veel resultaten op.

Na 20 dollar aan tokens had het precies één issue gevonden, dat direct werd gemeld en opgelost: https://github.com/gleam-lang/gleam/issues/5613. Eén is zeker meer dan nul. Maar er zijn veel problemen met "LLM-fuzzing": het is duur, niet deterministisch en voelt een beetje als het trekken aan de hendel van een gokkast.

Er was echter een ander idee dat ik had vermeden omdat het eerlijk gezegd naar heel veel werk klonk: structuurbewust fuzzing (structure-aware fuzzing).

Structuurbewust fuzzing

Software schrijven is moeilijk, en mensen zijn er niet goed in. Om te helpen hebben we andere software gebouwd die het zoeken naar bugs gedeeltelijk kan automatiseren.

Een van deze programma's is een fuzzer. Deze genereren gerandomiseerde inputs om in ons programma te voeren. De premisse is dat deze willekeurige inputs op grote schaal zodanig verdeeld zullen zijn dat edge cases naar boven komen die we nog niet hadden bedacht.

Fuzzers kunnen variëren van volledig willekeurige verzamelingen bytes tot hooggestructureerde, grammatika-bewuste AST's (Abstract Syntax Trees).

Het voeden van volledig willekeurige bytes aan een programma wordt meestal gedaan voor use cases die werken met afbeeldingen, bestanden, netwerkverzoeken, protocollen, etc. Er zijn talloze voorbeelden waar fuzzing echte beveiligingslekken en bugs in open-source software heeft gevonden. Zo is er een vondst door zzuf in Firefox, waarbij het omkeren van enkele bits in een afbeeldingsbestand leidde tot een crash van de browser: https://nvd.nist.gov/vuln/detail/CVE-2007-6715. Maar fuzzers hebben ook echt exploiteerbare beveiligingslekken via buffer overflows onthuld.

Er is een programma van Google, "OSS Fuzz", dat continu veel belangrijke open-source projecten fuzzt: https://google.github.io/oss-fuzz/

In ons geval werken we niet aan een browser of netwerkprotocol. We hebben een compiler. En dat opent de mogelijkheid voor structuurbewust fuzzing. Dat betekent dat we niet een stroom van willekeurige bytes genereren, maar eerder een stroom van code in de vorm van broncode of een AST.

Waarom Gleam?

Er zijn een paar dingen aan Gleam die het een bijzonder interessante kandidaat maken voor fuzzing:

  • Meerdere targets: Het genereert code voor twee targets: JavaScript en Erlang. We kunnen de output van hetzelfde programma voor beide targets vergelijken en eventuele verschillen markeren.
  • Minimalistische syntax: In ieder geval vergeleken met de meeste andere populaire programmeertalen. We kunnen geldige programma's genereren die bijna alle concepten van de taal dekken met relatief weinig code.
  • Statische typen: Dit is een geweldige functie waarmee we kunnen garanderen dat een programma niet tijdens runtime zal crashen. Dat betekent niet dat er geen bugs in het typesysteem kunnen zitten. Er zijn in het verleden problemen geweest met type-inferentie. Zoals we later zullen leren, vereist elk aspect van de taal zijn eigen testaanpak.
  • Functioneel karakter: Het feit dat alles een expressie is, maakt het samenstellen en structureren van programma's erg handig.
  • Rust: Dit wordt misschien over het hoofd gezien, maar het feit dat de Gleam-compiler zelf in Rust is geschreven, maakt het zeer eenvoudig om bestaande fuzzing-tooling te integreren. We kunnen delen van de compiler testen zonder een enkele .gleam-file te hoeven draaien.

Gebruikte bronnen

We gaan dieper ingaan op de technische aspecten van de fuzzer, maar ik zal niet te veel in detail treden over de code. Als je hier meer over wilt lezen, raad ik de post en blog van Nick Fitzgerald aan. Dit diende als de belangrijkste inspiratie voor dit project: https://fitzgen.com/2020/08/24/writing-a-test-case-generator.html

Onze fuzzer is gebaseerd op generatie (generation-based), niet op mutatie (mutation-based). Als je het verschil beter wilt begrijpen, raad ik dit artikel aan: https://fitzgen.com/2026/06/01/structure-aware-fuzzing-experiment.html

In dat artikel komt de auteur tot de conclusie dat, althans voor WASM, de mutatie-gebaseerde aanpak veel meer problemen vond dan de generatie-gebaseerde aanpak. Het is dus waarschijnlijk de moeite waard om dit in de toekomst voor dit project te implementeren!

Voor een nog diepere duik in het onderwerp, kijk dan naar deze bron: https://www.fuzzingbook.org/.

De volledige code voor de Gleam-fuzzer is te vinden in deze branch van mijn Gleam-fork: https://github.com/daniellionel01/gleam/tree/fuzzing

Fase 1: De parser

Een belangrijke ontwerpkeuze voor de fuzzer: gebruik de publieke compiler API. Hoewel er misschien geen stabiliteitsgaranties zijn voor de compiler API, maakt dit het eenvoudig om compatibel te blijven met toekomstige versies van Gleam. Het voorkomt ook dat we moeten prutsen met implementatiedetails, wat een goede manier is om te zorgen dat we geen valse positieven of negatieven creëren.

Hier zijn enkele voorbeelden van hoe onze parser de outputs vangt en categoriseert:

$ cargo run -p fuzzing-core --example classify
"pub fn main() { 1 }" -> compiled (js: 39B, ts: 32B, erl: 238B)
"pub fn main() { let f = fn(x) { x + 1 }; f(41) }" -> parse error
"pub fn main() { 1 +. \"x\" }" -> analysis rejected (javascript)
"pub fn main() {" -> parse error

Met behulp van de fuzz-crate en wat wrapper-code kunnen we de Gleam-compiler zeer snel bestoken met willekeurig gegenereerde inputs (nog niet gestructureerd), om te zien of we de compiler kunnen laten crashen in plaats van dat hij ons een foutmelding met context geeft.

We draaien het slechts één seconde, omdat de output vrij groot is:

$ cargo +nightly fuzz run parse_only --fuzz-dir fuzzing-harness -- -max_total_time=1 -timeout=10
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 302379076
INFO: Loaded 1 modules   (740945 inline 8-bit counters): 740945 [0x105eeac70, 0x105f9fac1),
INFO: Loaded 1 PC tables (740945 PCs): 740945 [0x105f9fac8,0x106aedfd8),
INFO:     2466 files found in fuzzing-harness/corpus/parse_only
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: seed corpus: files: 2466 min: 1b max: 4046b total: 425422b rss: 62Mb
#2467	INITED cov: 2434 ft: 8563 corp: 1249/171Kb exec/s: 0 rss: 108Mb
#2513	REDUCE cov: 2434 ft: 8563 corp: 1249/171Kb lim: 3764 exec/s: 0 rss: 108Mb L: 48/3753 MS: 1 EraseBytes-
#2645	REDUCE cov: 2434 ft: 8563 corp: 1249/171Kb lim: 3764 exec/s: 0 rss: 108Mb L: 8/3753 MS: 2 ChangeBit-EraseBytes-
#2656	REDUCE cov: 2434 ft: 8563 corp: 1249/171Kb lim: 3764 exec/s: 0 rss: 109Mb L: 2/3753 MS: 1 EraseBytes-
#2937	REDUCE cov: 2434 ft: 8563 corp: 1249/171Kb lim: 3764 exec/s: 0 rss: 109Mb L: 314/3753 MS: 1 EraseBytes-
#3183	NEW    cov: 2434 ft: 8578 corp: 1250/172Kb lim: 3764 exec/s: 0 rss: 110Mb L: 1054/3753 MS: 1 CopyPart-
#3591	REDUCE cov: 2434 ft: 8578 corp: 1250/172Kb lim: 3764 exec/s: 0 rss: 111Mb L: 99/3753 MS: 3 ShuffleBytes-CrossOver-EraseBytes-
#3934	NEW    cov: 2434 ft: 8585 corp: 1251/173Kb lim: 3764 exec/s: 0 rss: 112Mb L: 399/3753 MS: 3 CMP-CopyPart-CopyPart- DE: "\010\000\000\000\000\000\000\000"-
# ...
NEW_FUNC[1/7]: 0x0001031cfb88 in _RINvNtCs3kGMwX4aip8_4core3ptr9drop_glueINtNtCshX1O598ANu2_5alloc3vec3VecINtNtNtCs846PmCUGaYz_10gleam_core3ast8constant8ConstantuEEEB1f_+0x0 (parse_only:arm64+0x1002abb88)
NEW_FUNC[2/7]: 0x00010323bff4 in _RINvNtCs3kGMwX4aip8_4core3ptr9drop_glueINtNtNtCs846PmCUGaYz_10gleam_core3ast8constant8ConstantuEEBI_+0x0 (parse_only:arm64+0x100317ff4)
#16785	NEW    cov: 2483 ft: 8694 corp: 1262/177Kb lim: 3786 exec/s: 16785 rss: 144Mb L: 85/3753 MS: 1 CrossOver-
#18061	REDUCE cov: 2483 ft: 8694 corp: 1262/177Kb lim: 3797 exec/s: 18061 rss: 149Mb L: 403/3753 MS: 1 EraseBytes-
NEW_FUNC[1/4]: 0x00010312c3b0 in _RINvMs_NtCs846PmCUGaYz_10gleam_core5parseINtB5_6ParserINtNtB5_5lexer5LexerINtBT_14NewlineHandlerINtNtNtNtCs3kGMwX4aip8_4core4iter8adapters3map3MapNtNtNtB1F_3str4iter11CharIndicesNCNvBT_14make_tokenizer0EEEE23parse_bit_array_segmentNtNtNtB7_3ast7untyped11UntypedExprNCNCNvB2_21parse_expression_units6_00NvB2_17expect_expressionNvB5_24bit_array_expression_intEB7_+0x0 (parse_only:arm64+0x1002083b0)
NEW_FUNC[2/4]: 0x00010318893c in _RINvNtCs3kGMwX4aip8_4core3ptr9drop_glueINtNtCs846PmCUGaYz_10gleam_core3ast15BitArraySegmentNtNtBE_7untyped11UntypedExpruEEBG_+0x0 (parse_only:arm64+0x10026493c)
# ...
###### Recommended dictionary. ######
"\010\000\000\000\000\000\000\000" # Uses: 879
"\201\000" # Uses: 941
###### End of recommended dictionary. ######
Done 24887 runs in 2 second(s)

Als we kijken naar sommige van de artefacten die het produceert, zie je wat voor soort inputs er worden gegenereerd:

fn
ar(n,n,n,///A#
o
<0xE0><0xB0><0x8C><0xE0><0xB0><0x8C>「<0xE5><0xBD><0xB8><0xE4><0x95><0x85><0xE4><0x95><0x85><0xE2><0x85><0x85><0xE4><0x95><0x85>+�
">\u{000000000000000000.%\f0
fn
ar(n
ar:rn
a(

Het mooie hiervan is dat het alles kan testen zonder de Gleam-binary überhaupt te draaien. Het draait in-memory met de compiler-pipeline in Rust.

En raad eens! Toen ik deze fuzzer een tijdje liet draaien, vond hij daadwerkelijk een regressie op nightly, die niet voorkwam in v1.18.1 (de nieuwste versie van Gleam op het moment van schrijven):

$ cargo +nightly fuzz run --fuzz-dir fuzzing-harness parse_only fuzzing-harness/artifacts/parse_only/crash-8b14db5e4bf152924501e0818787026e9f5ea229
=fuzzing-harness/artifacts/parse_only/ fuzzing-harness/artifacts/parse_only/crash-8b14db5e4bf152924501e0818787026e9f5ea229`
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 3949856390
INFO: Loaded 1 modules   (750122 inline 8-bit counters): 750122 0x107773860, 0x10782aa8a),
INFO: Loaded 1 PC tables (750122 PCs): 750122 [0x10782aa90,0x10839cd30),
fuzzing-harness/target/aarch64-apple-darwin/release/parse_only: Running 1 inputs 1 time(s) each.
Running: fuzzing-harness/artifacts/parse_only/crash-8b14db5e4bf152924501e0818787026e9f5ea229
thread '<unnamed>' (23346909) panicked at /gleam/compiler-core/src/parse.rs:5226:52:
Token could not be converted to binop.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
==41428== ERROR: libFuzzer: deadly signal
#0 0x000109a27654 in __sanitizer_print_stack_trace+0x28 (librustc-nightly_rt.asan.dylib:arm64+0x87654)
#1 0x000106bcdf0c in fuzzer::PrintStackTrace()+0x30 (parse_only:arm64+0x1024a9f0c)
#2 0x000106bc1f48 in fuzzer::Fuzzer::CrashCallback()+0x54 (parse_only:arm64+0x10249df48)
#3 0x000181edb740 in _sigtramp+0x34 (libsystem_platform.dylib:arm64e+0x3740)
#4 0x000181ed18d4 in pthread_kill+0x124 (libsystem_pthread.dylib:arm64e+0x68d4)
# ... shortened ...
NOTE: libFuzzer has rudimentary signal handlers.
Combine libFuzzer with AddressSanitizer or similar for better crash reports.
SUMMARY: libFuzzer: deadly signal
────────────────────────────────────────────────────────────────────────────────
Error: Fuzz target exited with exit status: 77

De cruciale input was een pipeline |> in een const-expressie, zoals: const b = 1 |> 2.

Zodra we een probleem vinden, gebruiken we ook git bisect. Op die manier kunnen we onderscheid maken tussen een regressie op nightly en een probleem dat al in de nieuwste versie van Gleam zit.

Natuurlijk laten we dit idealiter niet slechts één seconde draaien, maar vele uren. Om eerlijk te zijn: de bugs die in deze stap worden gevonden zijn leuk om te vangen, maar je zult hiermee waarschijnlijk geen bug in de codegeneratie vinden.

Op het moment dat ik dit artikel publiceer, is het libFuzzer-pakket uit de branch verwijderd om de focus op type-veilige programma's te houden. Ik heb meer gerichte en efficiënte compiler-crashtests in gedachten voor een later stadium van dit project (namelijk tree-splicer).

Fase 2: Type-veilige programma's

Om type-veilige Gleam-programma's te genereren, gaan we een "smith" bouwen. We maken onze eigen vereenvoudigde versie van de Gleam AST waar we vervolgens op probabilistische wijze programma's voor genereren. Op die manier kan de smith, zodra hij besluit "we hebben een expressie nodig die resolveert naar een Int", een literale waarde zoals 3 leveren, of een anonieme functie fn() { 3 }(), of een variabele, enzovoort. Dit is hoe we betrouwbaar veel variatie tussen programma's creëren om uiteindelijk nieuwe edge cases te ontdekken.

Dit is hoe een van die programma's eruitziet:

pub const k_seed: Bool = False
pub const k_e: Int = 5
pub const k_golden: String = "data"
pub type V0 {
Number(value: String, inner: List(Int))
}
fn walk(xs: List(Int), acc: Int) -> Int {
case xs {
[] -> acc
[x, ..rest] -> walk(rest, acc + x)
}
}
fn f0(arguments: #(Float, String), l: Int, item: #(Float, Bool)) -> List(Int) {
[]
}
fn f1(class: Int, acc: String) -> Int {
100
}
fn f2(pair: Int) -> Float {
case "" <> "abc" {
"data" <> rest as whole -> fn(v1) { {
let whole = []
0.0
} }(0.0)
inner | "ab" <> inner -> {
let self_ = 3.14
fn(v2, v3) { self_ }("x", 4)
}
"b" <> b -> case fn(v4) { 42 }(2.0) {
9 -> {
1.0
} *. {
2.0
}
item -> 3.14
}
}
}
pub fn main() {
let v = walk([5], k_e) - 100
let z = f2(v)
echo [2]
echo {
case "res" <> "data", f0(#(1.0, "data"), v, #(0.5, False)) {
_, [] -> fn(v5, v6) { 10.0 }(False, "x")
"b" <> _, [] -> z +. {
2.0
}
k_seed, [2, h, ..] as whole -> 10.0
_, _ -> f2(5)
}
} /. {
0.5
}
echo {
fn(v7) { 100.0 }(False)
} *. {
case "b", 2 {
"ab", 1 -> {
let rest = k_seed
z
}
"x", 5 -> 2.0
_, v8 -> z +. {
0.25
}
}
}
}

Dat is een hoop wartaal. En dat is precies het punt! We genereren programma's die willekeurig geselecteerde geldige expressies combineren om hopelijk combinaties te ontdekken die leiden tot incorrect gedrag op één of beide targets.

Maar hoe weten we of een van onze Gleam-programma's incorrect gedrag produceert als het succesvol compileert? Hier komen de twee compilatie-targets om de hoek kijken. Als er bijvoorbeeld een bug zit in een programma met een case-expressie, kunnen we die ontdekken als de logica correct is geïmplementeerd in Erlang; we zouden dan een andere output krijgen op het JavaScript-target als we verschillende waarden in elke branch invoeren.

Dit is niet 100% waterdicht, maar een solide aanpak om mee te beginnen. Er kunnen nog steeds bugs in de compiler zitten die op beide targets voorkomen, waardoor we valse positieven kunnen krijgen. Zoals altijd: er is geen silver bullet.

Voordat we naar onze Gleam-smith kijken, moet ik een kleine zijsprong maken.

Het echo-probleem

JavaScript en Erlang hebben verschillende ideeën over hoe waarden tijdens runtime worden gerepresenteerd. In JavaScript heb je bijvoorbeeld geen aparte integer- en float-typen; je hebt Number. Sommige waarden worden anders weergegeven wanneer je ze converteert naar een string en de ingebouwde echo-keyword van Gleam gebruikt.

Neem dit programma:

pub type Wibble {
Wibble(wobble: Int)
}
pub fn main() {
echo "hello"
echo <<1, 2, 3>>
echo <<"a":utf8, "b":utf8, "c":utf8>>
echo [1, 2, 3]
echo Wibble(3)
echo 1.2
echo 1.0
}

Dit is de output van dat programma in Erlang vs. JavaScript:

Erlang:

$ gleam run --target erlang
src/app.gleam:6
"hello"
src/app.gleam:7
"\u{0001}\u{0002}\u{0003}"
src/app.gleam:8
"abc"
src/app.gleam:9
[1, 2, 3]
src/app.gleam:10
Wibble(3)
src/app.gleam:11
1.2
src/app.gleam:12
1.0

JavaScript:

$ gleam run --target javascript
src/app.gleam:6
"hello"
src/app.gleam:7
<<1, 2, 3>>
src/app.gleam:8
<<97, 98, 99>>
src/app.gleam:9
[1, 2, 3]
src/app.gleam:10
Wibble(wobble: 3)
src/app.gleam:11
1.2
src/app.gleam:12
1

In JavaScript wordt 1.0 geprint als 1, terwijl in Erlang de bit array <<1, 2, 3>> wordt geprint als "\u{0001}\u{0002}\u{0003}". Het record Wibble bevat geen labels in Erlang. Dit is niet ideaal als we de outputs direct in onze fuzzer willen vergelijken.

Ik kon aan een paar opties denken om dit probleem te omzeilen:

  1. Bewust de waarden niet testen waarvan we weten dat de echo-output divergeert.
  2. Een aangepaste echo-functie in Gleam bouwen en deze injecteren in de gegenereerde modules.
  3. Een aangepaste "parse deze output van Gleam"-functie in Rust bouwen, aangezien we uit de gegenereerde Module AST weten welke waarden in Gleam worden ge-echo'd.

Voor deze versie heb ik gekozen voor de derde optie. Omdat dit een zeer goed afgebakend probleem was, genereerde ik veel testcases en de bijbehorende code om elke echo-output van een Gleam-programma te parsen naar een Rust-enum om de outputs voorspelbaar te kunnen vergelijken. Dit is zeker niet perfect, maar het lijkt voor nu goed te werken.

Nu alles op hun plek staat, is het tijd om de fuzzer los te laten en 100k programma's te genereren, toch? Nou, hier moet ik nog een andere zijsprong maken.

Dubbele bevindingen en blokkerende problemen

Zodra je de fuzzer aan het werk zet, zal hij veel programma's genereren met een vergelijkbare vorm. Gevolgelijk, als je een bug ontdekt, blijf je programma's genereren die diezelfde bug reproduceren.

We hebben opnieuw een paar manieren om hiermee om te gaan:

  1. De Gleam-smith code aanpassen zodat bepaalde combinaties van expressies niet worden gegenereerd totdat het probleem is opgelost.
  2. Als er een fix beschikbaar is, een patch toepassen op de Gleam-fork die de fuzzer draait.
  3. Als het probleem een foutmelding is (en geen mismatch in waarden), een string.contains frase gebruiken om dit in de analyse over te slaan.

Op het moment van schrijven heb ik gekozen voor de derde aanpak. Ik heb de patch-aanpak geprobeerd, maar die gaat ervan uit dat de PR of jouw fix definitief correct is en geen nieuwe bugs introduceert. Het is beter om te vertrouwen op de exacte staat van Gleam zoals die in de officiële repository staat.

Ik ontdekte bijvoorbeeld twee problemen met de codegeneratie voor JavaScript; op dit moment negeert de fuzzer alle issues die een vergelijkbare signatuur genereren als die in de officiële repository zijn gemeld. Dit betekent dat ik potentieel andere codegeneratie-problemen oversla die een vergelijkbare foutsignatuur hebben. Voor een grootschaliger aanpak zou dit een goed idee zijn, aangezien ik de fuzzer nu in batches van 100 programma's draai en handmatig elke vondst verifieer.

// https://github.com/gleam-lang/gleam/issues/6182
fn is_gleam_issue_6182(raw: &str) -> bool {
raw.contains("SyntaxError: Unexpected token '&&'")
|| raw.contains("SyntaxError: Unexpected token ')'")
}
// https://github.com/gleam-lang/gleam/issues/6212
fn is_gleam_issue_6212(raw: &str) -> bool {
raw.contains("TypeError:") && raw.contains("is not a function")
}

De fuzzer gebruiken

Als je hier zelf mee wilt spelen in de repository:

$ cargo run -p fuzzing-cli -- run 948
=== fuzz ===
gleam:       /daniellionel01/gleam/target/release/gleam
seed:        948
erlang exit: 0
nodejs exit: 0
match:       OK
--- values ---
[0] == erl=Bool(false)  js=Bool(false)
[1] == erl=String("bcx")  js=String("bcx")
[2] == erl=Bool(false)  js=Bool(false)

Of bij een mismatch:

$ cargo run -p fuzzing-cli -- run 947
=== fuzz ===
gleam:       /daniellionel01/gleam/target/release/gleam
seed:        947
erlang exit: 0
nodejs exit: 0
match:       NO
--- values ---
[0] == erl=Int(2)  js=Int(2)
[1] != erl=Int(42)  js=Int(103)
[2] == erl=Bool(true)  js=Bool(true)

Je kunt het programma dat wordt uitgevoerd ook naar stdout printen:

$ cargo run -p fuzzing-cli -- print 947
pub const golden_value: String = "constructor"
pub const limit_value: Float = 2.0
pub const seed_value: Int = 42
fn yield(constructor: Float, prototype: Int, default: Float) -> List(Int) {
[7]
}
# ... lots more code ...

En batches programma's fuzzen als volgt:

# seed 947 is een bekende bug, maar er is momenteel geen goede heuristiek om deze te detecteren.
$ cargo run -p fuzzing-cli -- batch 900 100
[fuzzing-cli] gleam: /daniellionel01/gleam/target/release/gleam
[fuzzing-cli] seeds 900..999
[fuzzing-cli] 10/100 run, 0 mismatches, 0 skipped
[fuzzing-cli] 20/100 run, 0 mismatches, 0 skipped
[fuzzing-cli] 30/100 run, 0 mismatches, 0 skipped
[fuzzing-cli] 40/100 run, 0 mismatches, 0 skipped
[fuzzing-cli] DIVERGENCE seed 947 -> erlang:0 nodejs:0
[fuzzing-cli] 50/100 run, 1 mismatches, 0 skipped
[fuzzing-cli] 60/100 run, 1 mismatches, 0 skipped
[fuzzing-cli] 70/100 run, 1 mismatches, 0 skipped
[fuzzing-cli] 80/100 run, 1 mismatches, 0 skipped
[fuzzing-cli] 90/100 run, 1 mismatches, 0 skipped
[fuzzing-cli] 100/100 run, 1 mismatches, 0 skipped
[fuzzing-cli] done: 100 programs, 1 mismatch(es), 0 skipped

En dat is alles! Ik loop door batches van 100 tegelijk, bekijk eventuele crashes of verschillen in de Erlang vs. JavaScript output en bepaal of het een bekend programmapatroon is of iets nieuws.

Resultaten van de fuzzer

Tot nu toe heeft de fuzzer al 9 issues gevonden! Eén daarvan is gesloten en upstream gemeld aan Erlang/OTP, wat een erg vette vondst is: https://github.com/erlang/otp/issues/11494

  • #6179 — Onbereikbare branches zorgen ervoor dat Erlang codegen paniekt
  • #6180 — Een type genaamd Record geeft een waarschuwing van Erlang
  • #6181 — Matching op <<_:utf8>> neemt de verkeerde branch op JavaScript
  • #6182 — Matching op <<"":utf8>> genereert incorrect JavaScript
  • #6187 — Erlang gooit een fout bij twee opeenvolgende echos met een lijst en een lege string
  • #6192 — Compiler crash op nightly bij gebruik van |> in een const
  • #6212 — Functie aangeroepen als wibble overschaduwt een lokale variabele op JavaScript
  • #6213 — JS codegen gebruikt de verkeerde variabele wanneer een let een externe variabele overschaduwt

Het leuke is dat we met de huidige staat van de fuzzer nog niet eens alle potentiële expressies in Gleam dekken. Dit is één aanpak van fuzzing met een beperkte subset van Gleam-programma's. Wanneer we dit uitbreiden naar alle mogelijke expressies en statements in Gleam, en meer manieren introduceren om de correctheid van programma's te testen, zal dit een ongelooflijk nuttig instrument worden voor de Gleam-community om de tool verder te harden.

Het is ook vermeldenswaard dat grotere projecten zoals esbuild, Babel en Elm, die compileren naar JavaScript, nog steeds issues hebben met JavaScript-codegeneratie, zelfs nadat ze al vele jaren bestaan. Codegeneratie, vooral voor JavaScript, is een echt heel moeilijk probleem. Zeker als je bedenkt dat Gleam ook compileert naar Erlang, wat niet verder af zou kunnen staan van een taal als JavaScript, terwijl het beide targets ongelooflijk goed balanceert!

Toekomstig werk en opkomende problemen

Dit project staat nog in de kinderschoenen en er is veel waar we in de toekomst aan kunnen werken:

  • Schaalbaarheid: Ik vraag me af hoe je fuzzing op grote schaal aanpakt. Ik weet zeker dat andere bedrijven speciale servers en hardware hiervoor hebben. Maar delen van de fuzzer kunnen ook in GitHub Actions draaien. Een goede workflow zou zijn dat dit beschikbaar is voor de Gleam core maintainers, zodat ze een aanzienlijk aantal gefuzzde programma's kunnen draaien bij het reviewen van een PR en voordat ze een nieuwe RC uitbrengen.
  • Review-proces: Op dit moment kan het reviewen van de vondsten van de fuzzer handmatig gebeuren en met hulp van een LLM. Het vinden van een betrouwbare aanpak om te stoppen met het produceren van dezelfde programmavormen voor bekende en nog niet gefixte issues, lijkt een zeer hoge ROI te hebben (waarbij de investering tijd is).
  • Language Server: Ik denk dat het implementeren van de fuzzer voor de language server veel nieuwe edge cases zou blootleggen, aangezien deze erg gevoelig is voor acties die worden getriggerd in onvoorziene staten.
  • Metamorfe testing: Metamorfe testing zou een goede manier zijn om de output van een programma te testen zonder beide targets te hoeven vergelijken.
  • Generics en Type-inferentie: Er zijn issues geweest met generics en type-inferentie in de Gleam-compiler die zeker de moeite waard zijn om te fuzzen. Dit vereist een complexere program-smith dan nu het geval is.
  • Standaardbibliotheek: Je kunt veel inputs fuzzen voor officiële Gleam-pakketten, vooral de standaardbibliotheek gleam_stdlib. Er zijn eerder inconsistenties geweest in het gedrag op beide targets.
  • LLM Fuzzing 2.0: Ik zou de LLM-fuzzing aanpak nog een kans geven als ik fysieke hardware had, zoals een verouderde maar krachtige GPU om een "goed genoeg" open weight model in een loop te draaien en het 24/7 nieuwe benaderingen te laten ontdekken in een goed geconstrueerde harness.
  • Delta Debugging: Ik denk dat dit paper ongelooflijk cool zou zijn om te implementeren voor deze use case en mogelijk zou helpen bij het dedupliceren van issue-rapporten: https://www.cs.purdue.edu/homes/xyzhang/fall07/Papers/delta-debugging.pdf

Conclusie

Ik heb veel plezier in het schrijven van code wanneer ik werk in een codebase waar ik aan iets kan werken zonder angstig te zijn over de integratie en het testen van die wijzigingen.

En het beste aan een testsuite is dat deze meestal additief is. Elke nieuwe edge case en bugfix kan in de toekomst meer veiligheid bieden.

Ik ben super enthousiast over de toekomst van Gleam, en eraan bijdragen is zeer belonend geweest.