Opening https://tokyo6.tokyo/ with Omoikane caused a bug where the script terminated with a SIGSEGV during execution. Issue #1223 (issue #1223) reproduces the problem not only in the GUI but also in the screenshot feature that renders the screen to an image. The backtrace from the debug build reproduced on a Linux aarch64 Docker development environment with Xvfb showed that Boa’s garbage collector halted while traversing references to an object.
The fix in PR #1260 (PR #1260) addressed the combination of an ephemeron—a conditional reference—and the generational garbage collector. A young object that was referenced by an older object was being reclaimed during the minor GC that followed a major GC. Subsequently, the major GC followed a reference that had already been freed, causing a crash.
The basic usage of WeakMap was explained in the previous article “What Is a WeakMap?” (source). Here we will examine why the referenced object disappeared by looking at the garbage‑collector implementation. The implementation details are based on the main commit verified on October 8 (source) and the diff and regress tests from the fix PR.
Where the “weakness” of a WeakMap applies
A normal Map retains the registered keys and values. As long as the Map itself remains alive, both the keys and the values that it alone references are still usable.
In WeakMap, the key is not kept alive merely because it was registered. For example, this can be used when you want to attach auxiliary information to an object.
const metadata = new WeakMap();
let element = { name: "button" };
metadata.set(element, { clicks: 0 });
console.log(metadata.get(element).clicks); // 0
element = null;
After the final assignment, the ordinary reference from this code to the key disappears. Registering something in a WeakMap does not keep the key alive on its own. However, this does not mean that the key is collected immediately after the assignment. When it is collected depends on the garbage collector, and there is no JavaScript code that explicitly requests collection at that moment. The ECMAScript specification for WeakMap also notes the constraints on collection timing and explains why WeakMap does not provide a mechanism to enumerate its keys. (source)
On the other hand, as long as the key is still usable, it would be problematic if the value could not be retrieved with metadata.get(element). If the value were also held with a normal weak reference, the auxiliary information would disappear even though the key itself remains alive.
In other words, a WeakMap entry must satisfy the condition “retain the value as long as the key is reachable.” Simply making both the key and the value weak does not express this relationship.
References from value back to key
There is also a problem with an implementation that makes only the key a weak reference while unconditionally holding the value strongly.
const metadata = new WeakMap();
let element = { name: "button" };
metadata.set(element, { owner: element });
element = null;
The value owner references the key. If the WeakMap always follows the value with a strong reference, it can trace back from the value to the key. As a result, even when no code outside the entry uses the key, the WeakMap itself keeps the key alive via the value.
This is where an ephemeron is used. It does not enable the reference from the entry to the value until the key’s liveness can be confirmed. Even if there is a reference from the value back to the key, that reference alone cannot satisfy the liveness condition for the entry itself.
flowchart LR
R[Normal root] --> K[Key]
W[Live WeakMap] --> E[Entry]
E -. Retain based on the condition that the key is reachable.-> V[Value]
V --> K
If the line from the root to the key in the diagram disappears and there are no other references keeping the key alive, the key cannot be rescued by using the reference from the entry to the value. Conversely, if the value itself is strongly referenced from another root, the key becomes reachable through the value’s owner. The determination considers all references, including those outside the entry.
In Boa, this conditional reference is represented by Ephemeron and EphemeronEdge (source code). This is the Boa implementation that is embedded in Omoikane. The behavior of JavaScript’s WeakMap and the internal GC types that realize it must be read separately.
Not Determined by a Single Traversal
Whether an ephemeron’s key is alive may not be decided by the initial strong‑reference traversal alone.
Consider two entries. The first key, keyA, is reachable from the root. Its value, valueA, strongly references the second key, keyB.
root → keyA
Entry A: retain valueA if keyA is alive
valueA → keyB
Entry B: retain valueB if keyB is alive
When the ordinary strong references are traversed first, we see that keyA is alive. Next, because the condition for entry A is satisfied, we can follow valueA. At that point we discover that keyB is also alive, allowing us to traverse valueB from entry B.
If entry B had been examined first, the condition would not be met on the initial pass. Concluding that the key is dead at that point would cause an incorrect collection. Unresolved entries must be left pending, and after the value‑traversal reveals new reachability relationships, they need to be evaluated again.
The state where no new reachability relationships appear is called a fixpoint. Boa’s mark_heap (source code) also leaves unresolved ephemerons in pending_ephemerons and repeatedly processes the traversal of values that satisfy the condition. When the number of unresolved entries stops changing, this loop terminates.
The “unreachable” status observed at this stage is not necessarily the final result. In the bug at hand, the timing of this determination was affected by the generational GC’s metadata.
References from older parents to younger children
Generational garbage collection manages objects by dividing them into generations. In Boa’s implementation, newly allocated objects are placed in the young generation, and objects that survive a collection are promoted to the old generation.
Minor GC targets the young generation for collection, while major GC examines reachability across both the old and young generations. If every minor GC were to scan all old objects, the advantage of focusing solely on the young generation would be lost. Therefore, references from old objects to young objects are recorded separately.
root → old parent → newly created young child
When a new child is stored in an old parent, the child can be accessed through the parent. If a minor GC looks only at the young generation and overlooks the reference from the parent, it may collect the child.
The mechanism that detects such writes and notifies the garbage collector is called a write barrier. In the Boa approach relevant to this case, a modified old parent is recorded as a “dirty parent,” and the minor GC scans that parent. “Dirty” means that the parent’s contents have changed and its references to children need to be re‑examined.
mark_minor (source code) resolves the young‑generation reachability relationships starting from these records and the roots. Even though an old parent is not a candidate for collection during minor GC, it becomes a scanning target for discovering its young children.
Child disappears after a major GC
The problematic reference relationship was one where the parent itself was the value of an ephemeron.
root → key
Live ephemeron — retained based on the condition that the key is reachable → old parent
old parent → young child
The key is alive. Consequently, the parent is also alive, and the child referenced by the parent must be retained. However, the fact that the parent is reachable is only determined when the ephemeron is processed.
In the major GC before the fix, when the dirty‑parent record was processed and the parent was found to be unreachable, that record was lost. At that point the ephemeron’s fixpoint had not yet been resolved. Later, when the key’s reachability was confirmed, both the parent and the child were marked and survived without being collected during the major GC. However, the record of the dirty parent that should have been passed to the next minor GC disappeared.
Write the young child into the old parent
→ Record as a dirty parent
→ During the initial scan of a major GC, the parent has not yet been reached
→ Lose the parent's record
→ Resolve the ephemeron and keep the parent and child alive
→ In the next minor GC, overlook the reference from parent to child
→ Reclaim the child
Even if the major GC correctly determines that both a parent and its child should remain alive, that is not sufficient. The next minor GC does not re‑scan the entire old generation, so it must retain the records needed for that scan.
When a child is collected, the parent still retains a reference to the now‑freed child. This does not necessarily cause an immediate crash. In the PR’s explanation of the cause (PR #1260), a later major GC that followed that reference resulted in a SIGSEGV. The mark operation that appeared in the backtrace occurred after the point where the invalid reference was created, at the location where it was used.
Changing the timing of discarding records
In the fix, the record of dirty parents is retained until the ephemeron is resolved.
The change at mark_heap stores the extracted parent into dirty_parents_pending_sweep. Parents that have already become reachable undergo the necessary re‑scan, but records of parents that have not yet been marked are also retained.
After that, once the reachability relationships involving ephemerons are resolved, retain_major_remembered finally retains only the parents that are still reachable. As a result, parents whose liveliness is later discovered via an ephemeron can also be used as references in the next minor GC.
Here, the record of a dirty parent must not itself become a root for the major GC. Keeping a parent alive merely because it was rewritten would cause us to retain parents and children that could otherwise be reclaimed. The record is merely a clue for scanning, not an owner that guarantees survival during the major GC. The fix selects the records based on the final mark results.
The same PR also includes a change to the timing of clearing the minor‑GC marks. The marks set during this minor GC are retained until the promotion processing for both objects and ephemerons has completed.
In the previous ordering, the marks for young values were cleared first, after which the ephemeron promotion step would scan those values and possibly re‑mark them. If such a mark persisted until the next collection, it would cause the scanner to skip the newly written references. The “reached” flag must be aligned with the specific collection it belongs to.
Now, after the promotion step, clear_survivor_minor_marks is called, clearing the marks for both the objects that remained in the young generation and those that were promoted in this cycle. The next minor GC can then examine references without being hindered by the marks from the previous collection.
Verify before reading a released child
The regression test major_collection_preserves_dirty_parent_reached_through_ephemeron creates the problematic reference relationship directly using the GC’s type.
First, the key and the parent are root objects, and a minor GC is run twice to confirm that the parent has been moved into the old generation. A young child is then attached to that parent, the parent’s direct root is removed, and the child is retained as the value of an ephemeron. The key’s root remains.
*parent.child.borrow_mut() = Some(GcEdge::new(42_u32));
let ephemeron = Ephemeron::new(&key, parent.into_edge());
force_collect();
force_minor_collect();
This excerpt is taken from the test. parent.into_edge() changes the root that directly holds the parent into a reference that the GC follows. After a major GC, a minor GC is run, and we verify whether the parent and child remain.
The verification does not start by immediately reading the child’s value 42. If the child has already been collected, that read would itself be access to freed memory.
First, we assert that the child’s pointer is present in the GC’s list of young allocations or in the old‑generation allocation list. Only after that check passes do we read the child’s value. The PR notes that before the fix this allocation‑membership assertion failed, while after the fix it succeeds. Instead of waiting for a crash, the test is configured to fail as soon as an incorrect collection occurs.
The counterpart test dirty_ephemeron_parent_is_reclaimed_when_its_key_dies also removes the key’s root before running the major GC. It verifies that the ephemeron no longer holds a value and that the allocation counts for the strongly referenced parent and child objects drop to zero.
If the key is alive, the parent and child are retained; if the key dies, they are collected. By exercising both scenarios, we can detect a fix that, in order to prevent premature collection, leaves even unreachable objects behind.
Verification on actual pages
In the verification record of PR #1260, all 53 tests for boa_gc passed, along with the full‑root test, the doc‑test, and a GUI‑enabled build. The minimal reproducible page was run twice via screenshot, and the full set of saved pages was run once; each run exited with status code 0. The actual https://tokyo6.tokyo/ also exited with status code 0 in the screenshot.
In the test where the saved pages and the live site were opened in a GUI under Xvfb, we observed the process’s continued existence and its rendering for 15 seconds after the title appeared. This result corresponds to the conditions recorded in the PR. It is not a re‑run in a different environment performed for this article.
In the previous case where a getter’s return value was collected during a GC that occurred while the inline cache was being updated (source), a temporary root before the value entered the VM register was missing. In this situation, there was a parent reachable via the key and an ephemeron, and that parent also had a reference to its child. What was missing was the record needed to locate cross‑generational references in the next collection.
After the fix, we retain dirty parents using the final mark results that resolve the ephemeron’s fixed point, and we clear the minor‑GC marks only after the promotion processing has completed. This ordering aligns the conditional liveness determination with the management information passed to the next generational collection.