While running Omoikane’s Acid3 test, the process hit a SIGSEGV under the baseline JIT configuration on macOS ARM64. I briefly mentioned this in the earlier blog post about Omoikane updates, but the cause wasn’t the test itself nor the JIT’s machine‑code generation; it lay in the Boa code we’re incorporating, specifically in the Array.from() section. Omoikane incorporates Boa’s source code together with the browser‑side implementation, so the fix was also applied to the engine that pulls in that code. (source)

We failed to keep the function obtained from the Symbol.iterator getter alive in a way that it remains usable even if a garbage collection runs midway. The fix itself is a single line that calls root(), but the process of isolating the issue up to that point and creating a test that is guaranteed to fail before the fix was interesting, so I’m leaving it as a separate article. This is a separate issue from the Promise and shared‑shape problems we dealt with before. The investigation records are documented in Omoikane Issue #777.

Distinguishing Acid3 failures from timeouts

The issue was discovered in the browser CI for PR #775, which added form‑related functionality. Acid3 has two execution paths: the “Faithful” path, which advances using the page’s native events and timers, and the “DirectDrive” path, where the test itself drives the update process. Faithful had already completed, including the runtime teardown and a forced garbage collection, but during the subsequent DirectDrive phase only the macOS ARM64 baseline JIT configuration halted with signal 11.

If you don’t separate these two paths, you can only say “Acid3 failed.” The fact that Faithful finished does not mean the entire test completed successfully; it indicates that execution then moved into a different path during the DirectDrive phase. A SIGSEGV is the result of the OS detecting an illegal memory access, which is distinct from JavaScript exceptions or a drop in the test score.

We also examined timeouts separately in the same Acid3 run. However, it is necessary to differentiate cases where the time limit was reached from cases where the process crashed due to an illegal memory access. Re‑running the failing CI once reproduced the SIGSEGV during the same DirectDrive phase. In contrast, running Acid3 four times on the same Linux ARM64 configuration succeeded every time. At this point, we cannot say the issue is unique to macOS or to the JIT.

If it’s a garbage‑collection lifetime problem, the way it manifests can vary depending on where reclamation occurs and what other references remain at that moment, even when the input is identical. The fact that it succeeded four times on Linux is important, but that does not alone justify dismissing the possibility that Boa’s reference‑holding is at fault. Conversely, the fact that it reproduced on macOS alone does not prove that the JIT’s generated machine code is incorrect. Here we needed to determine exactly where the failure occurred.

To that end, we set up a macOS diagnostic CI that pins the code revisions before and after the change (CI run). The main branch and the pre‑fix PR revision succeeded on all four normal runs and all four runs with LLDB attached, whereas the PR revision that exhibited the problem failed on both normal runs and runs performed with LLDB. The execution path that halted, as observed with LLDB, is as follows.

In the diagnostics we varied the combination of source files in the same environment and recorded the results separately for normal execution and execution with a debugger. Because attaching a debugger changes timing and memory layout, success only with LLDB does not guarantee safety. The revision that exhibited the problem failed abnormally in both cases.

Array::from
  -> get_iterator_from_method
  -> JsObject::call
  -> __call__

It halted at the point where it tried to reference the vtable of an iterator function. This isn’t a log that directly pinpoints the cause, but it narrows the investigation from the entire Acid3 test to the vicinity of the call to Array.from(). Moreover, this revision comparison alone does not prove that the PR introduced a new bug; the change in execution order or garbage‑collection timing may have simply exposed a pre‑existing lifetime hole.

If it’s merely a vtable reference that caused the crash, we still don’t know whether the function object itself was corrupted or whether some other memory corruption occurred earlier. However, it did give us a clue about where the function obtained via GetMethod is kept until it is actually invoked. From that point we revisited the implementation and specification order of Array.from().

Array.from() obtains the function before producing the output

According to the ECMAScript specification for Array.from() (source), the path that handles iterables proceeds roughly as follows.

Retrieve the Symbol.iterator method from items
  -> Execute the constructor of the output target, or create an array
  -> Call the retrieved method to obtain an iterator

The key point is to first obtain the method and create the destination object before actually invoking that function. In the case of Array.from.call(Destination, items), the constructor of Destination runs during this gap. Since a JavaScript constructor can perform arbitrary work, a garbage collection may occur there. The implementation must keep the method that was just obtained alive throughout that interval.

This ordering is not merely an implementation detail; it is visible from JavaScript. For example, the following code leaves log entries for each call to the getter, the constructor, and the iterator function. This demonstrates the standard execution order and is not a snippet intended to reproduce a garbage‑collection bug.

const order = [];
const source = {
  get [Symbol.iterator]() {
    order.push("get");
    return function () {
      order.push("call");
      return [37][Symbol.iterator]();
    };
  },
};
function Destination() {
  order.push("construct");
}

const result = Array.from.call(Destination, source);
console.log(order); // ["get", "construct", "call"]

If the implementation is rearranged so that the retrieved iterator function is called before the constructor, the call order becomes get, call, and construct. When the constructor throws an exception, the iterator function should not be invoked in the original order, but it would be called after the reordering. Therefore, changing the call order merely to avoid a garbage‑collection hazard does not constitute a fix.

Normally, items[Symbol.iterator] points to a function on the object, so following items often leads directly to that function. However, the situation changes when the property is a getter. Even if the getter creates a fresh function each time it is called, there is no need to store that function as a property of items.

The specification’s GetMethod checks whether the value obtained via the key Symbol.iterator is callable. If that property is a getter, retrieving the value itself executes JavaScript code. Boa’s implementation (source code) also fetches the property and returns it as JsObject if it is a callable object. Whether the value returned here remains reachable from items thereafter is not guaranteed solely by the return value of GetMethod.

items --getter--> new iterator function
                   |
                   +-- Only Boa's temporary variable is retained

Garbage collection occurs while executing the output constructor
                   |
                   +-- Attempt to call a function afterward

More precisely, when GetMethod returns undefined, it follows a different path that treats it as array‑like. The flaw we’re dealing with lies in the iterable‑side path where a callable iterator method is found. The fact that the function was created anew by a getter is also one of the reasons why this issue was not easily exposed when simply passing a regular array to Array.from().

In Boa’s modified code, we first obtain the method via items.get_method(JsSymbol::iterator(), context)?. If it’s Some(using_iterator), we proceed to the iterable side, generate the output destination, and then call items.get_iterator_from_method(&using_iterator, context)?. The implementation of the latter here actually invokes the supplied function through method.call(self, &[], context). The issue lies in the output generation that occurs between obtaining the method and invoking it. Control is handed off to a user‑defined constructor partway through.

Rust variables and GC roots are separate

Boa stored the return value of GetMethod in a local Rust variable called using_iterator. However, the fact that a Rust variable is in scope is not the same as registering the object with the JavaScript garbage collector as “still alive.” In particular, along this path the getter’s return value was not guaranteed to be referenced by any other JavaScript object.

Boa’s implementation distinguishes between JsObject, which represents a reference to the heap, and a root that is explicitly held from outside the heap. root() creates a handle that is registered as an external root. What was actually needed was to treat the obtained function as a value reachable by the garbage collector from the moment the method is fetched until the iterator is obtained.

This distinction runs counter to the intuition that “since Rust manages ownership, as long as the variable remains in scope it should be fine.” Looking at the definition of JsObject (source code), we see that it contains GcEdge. The description of GcEdge (source code) states that it is a reference placed inside the heap that the garbage collector tracks, but it is not itself registered in the external root set. Even if you call JsObject with clone(), you only create an additional reference to the same object; this does not register the native‑code local variable as a root.

In contrast, JsObject::root() returns RootedJsObject. The implementation of Rooted (source code) performs register_root at creation time and unregister_root when the handle is destroyed. Root registration is counted against allocations that are subject to garbage collection, and an entry is added to the root registry when the count rises from zero to one. The garbage collector’s mark phase begins by traversing from the objects registered there. Simply having a pointer on the Rust side is not the same as being included as a starting point for the GC’s traversal.

Before fix: the function returned by the getter → JsObject / GcEdge
                           -> Stored in a native local variable, but the external root is not registered

After correction: function returned by the getter → RootedJsObject
                           -Reachable from root registry by GC

Of course, Boa’s roots are not limited to this registry. The VM’s value stack is also registered as RootProvider, and the garbage collector examines any values remaining there. This mechanism later explains why the initial JavaScript‑getter version of the test did not reproduce the problem. Even a function that should have no root can sometimes be reachable from another execution frame.

The crucial point is not to keep the function alive forever. The required lifetime begins after receiving the result of GetMethod, spans the interval where a garbage collection could occur inside the constructor, and ends when GetIteratorFromMethod invokes that function. It suffices to cover this interval with an explicit root; there is no need to keep rewriting the input object’s properties to retain the function after the iterator has been obtained.

What can be concluded from the crash that occurred in the vtable

When combined with the LLDB stop point and the tests before and after the fix described later, the most plausible explanation is that the call proceeded without the lifetime of the retrieved function being guaranteed. However, the logs captured only at the moment of the crash do not allow us to trace precisely that “this address was collected in this GC cycle and reused for another allocation.” We should keep this distinction in mind when assessing the strength of the diagnosis.

What we were able to confirm is that, in the path that calls the function handed from Array.from(), a vtable reference caused a SIGSEGV; a small test that forces a garbage collection inside the constructor failed before the fix; and when that function is registered as a root, both the small test and the original Acid3 diagnostic succeeded. This combination strongly suggests a missing reference retention, but the macOS debugger logs alone do not prove that the memory was actually reused.

Moreover, it would be premature to conclude that the JIT code generation must be fixed solely based on the CI symptoms observed only when JIT is enabled. In a JavaScript engine, even when running the same JavaScript code, the way the interpreter or JIT uses registers and how object lifetimes are perceived can differ depending on the OS and architecture. These differences can either hide or expose garbage‑collection bugs. Later, a unit test on Linux ARM64 also failed, so any explanation that limits the cause to the macOS JIT is invalid.

Even with forced GC, the initial test still passed

Repeating only the failing CI runs makes it hard to determine whether the fix truly addressed the root cause. Therefore, we created a small regression test in which the getter returns a new iterator function and the output constructor performs a forced garbage collection.

The goal is not to shrink the entire Acid3 suite, but to reproduce the reference graph described above in the simplest possible way. We keep the input object and the getter itself alive, but arrange for only the function returned by the getter to have no storage anywhere. Then we trigger the garbage collection not immediately after GetMethod, but inside the output constructor. Even if the GC were run before the getter, the temporary function it would eventually produce does not yet exist. Running it after the iterator is called also skips the problematic interval.

Initially we wrote the getter in JavaScript, but even before the fix it failed to reproduce the issue. The return value can remain in the registers of the JavaScript frame after the getter finishes, inadvertently keeping the function alive as an unexpected reference. In garbage‑collection tests, a false negative—where a root that should not exist happens to remain—is particularly troublesome.

This does not mean that a getter written in JavaScript will never cause a bug. In the specific execution path of the test, a value that should have disappeared from the frame was accidentally protected, allowing the test to pass. As we saw earlier, Boa’s garbage collector also treats the VM’s value stack as a root. When the getter is executed in JavaScript, whether its return value stays on the VM side affects reproducibility. The fact that the test did not crash even after inserting a forced GC alone does not prove that the reference retention was correct.

In the final regression test (source code), we generate and return the function from a native Rust getter. In the output constructor we invoke the test‑specific forceCollect(), and then verify whether the previously obtained function is actually used.

In the implementation, we root source and set a native getter on Symbol.iterator. The getter itself is also kept rooted for the required duration. However, the new iterator function returned by the getter is not stored in the property of source. If the source or the getter itself were collected at this point, it would cause a different failure from the issue we’re trying to observe, so this distinction was necessary. The JavaScript side during testing looks like this. forceCollect() is a function registered by the test and is not an API that can be used on ordinary web pages.

let calls = 0;
function Destination() {
  forceCollect();
  this.marker = "destination";
}

const result = Array.from.call(Destination, source);
result instanceof Destination &&
  result.marker === "destination" &&
  result.length === 1 &&
  result[0] === 37 &&
  calls === 1;

The function returned by the getter increments calls when called and returns [37][Symbol.iterator](). In addition to confirming that the result is 37, we also verify the type of the output object, the marker attached by its constructor, the array’s length, and even the number of times the iterator function has been invoked. Without checking these details, there remains a possibility that we merely avoided a crash by taking a different execution path.

Native getter returns a new iterator function
  -Array.from executes the output constructor
  -The constructor forces garbage collection
  -Calls the stored iterator function
  -Inspect the result’s type, value, length, and call count

With this change, even on the pre‑patch Linux ARM64 build the program terminated abnormally, and the crash path matched the iterator call from Array.from() that we observed in Acid3. Rather than dismissing it as a random CI failure on macOS, we were able to reproduce the same lifetime issue with a small, isolated test.

Verifying reproducibility was more important than writing a test that passes only after applying the fix from the start. Restoring the code to its pre‑patch state and causing the test to fail, then re‑running the same test after the fix succeeds, confirms that the test truly hits the bug we intended to fix. Because the failure also occurred on Linux ARM64, it was not a regression that could be examined only with a macOS runner or LLDB.

Root before the constructor

The commit that introduced the fix (commit) added the following single line to Array.from():

let using_iterator = using_iterator.root();

It was placed after obtaining the function from GetMethod, but before proceeding to the output constructor or ArrayCreate. The root is also used later at get_iterator_from_method. We did not reorder the calls or arbitrarily store the getter’s return value in the input object, because either change would alter behavior observable from JavaScript. It does not halt the garbage collector; instead, it is a fix that guarantees the function’s lifetime only for the required period, preserving the order mandated by the specification.

Although the same name let is used, this is not merely a variable rename. The right‑hand side, using_iterator, refers to JsObject, while the left‑hand side becomes a handle returned by root() that is already rooted. The subsequent call to get_iterator_from_method is made while retaining that root. When the call finishes and the function’s scope exits, Rooted’s Drop removes the root registration. Even if an intermediate constructor throws an exception and Rust’s ? returns early, the root is not left dangling.

In the code, the output object a is also retained after creation via a.clone().root(). However, that root protects the lifetime of the output object, not the lifetime of the iterator function returned by the getter. There is no guarantee that the destination will later reference that function. Treating this as the same “object used in the middle of Array.from()” would confuse which entity actually needs to be kept alive.

Similarly, merely incrementing using_iterator.clone() does not increase any external root registrations, and even if source is rooted, the getter’s return value is not stored in source. In the tests we actually root source. Nevertheless, the fact that the pre‑patch version failed clearly illustrates this reference relationship.

The location where the root is placed is also important. Rooting after the constructor is insufficient if the garbage collector runs inside that constructor. Conversely, there is no need to unconditionally root the function all the way through the array‑like path where GetMethod returns undefined. The point at which we root immediately after identifying Some(using_iterator) matches both the required execution path and the necessary lifetime.

Verifying the fix in three stages

After the fix, the aforementioned regression test passed. All 1,050 unit tests of the Boa engine with baseline JIT enabled, as well as the full 2,694 tests for Omoikane, succeeded. Moreover, applying the same three‑line fix to the problematic revision in the macOS diagnostic CI (CI run) resulted in both Acid3 paths achieving a perfect 100/100 across all eight runs—four without LLDB and four with LLDB. The test time limits and fixtures were not altered.

Here we separate the roles of the verification steps. First, the pre‑patch failure and post‑patch success of the regression test confirm that we are indeed hitting the intended reference‑holding bug. Next, the macOS diagnostic run that applies only the three lines of reference‑holding code to the problematic PR revision checks whether the original symptom disappears without incorporating any other changes. Finally, the full CI of the final PR examines whether any regressions have occurred in the broader feature set. This distinction is more important than merely counting the total number of passing tests.

Even in the final PR’s CI, the browser tests covering six configurations—Linux x86_64, Linux ARM64, and macOS ARM64, each with both interpreter and baseline JIT—succeeded (CI run). In the Test262 comparison (CI run), 50,595 cases were run on each platform, and this fix introduced no regressions, panics, or discrepancies among the three environments. Known unsupported cases remain, so full conformance is not claimed.

Since the PR concerned form‑related functionality, we are also continuing the fixed‑WPT verification (PR #775). Out of 147 cases, 146 passed, one was a known unsupported case, and there were no new regressions. While this does not directly prove the cause of the garbage‑collection issue, it provides evidence that incorporating the Boa‑side fix does not break existing browser behavior.

Of course, these results do not prove that every GetMethod call in Boa is now safe. What we have confirmed is the regression targeting the specific Array.from() interval and the set of tests maintained in the PR. Since we have not measured the fine‑grained performance impact of adding the new root, we do not claim that the change both makes it safe and leaves performance unchanged.

How to look for similar issues

The bug we discovered appeared on macOS’s baseline JIT and in Acid3, but the underlying cause lay earlier, in Boa’s temporary reference lifetime. Because we could also cause the extracted regression test to fail on Linux ARM64, we do not treat it as a problem limited to macOS or the JIT. While success or failure across different execution configurations can hint at reproducibility conditions, it does not directly reveal the boundary of the offending code.

When investigating similar problems, the first step is to map out “when the value is obtained and when it is last used.” In this case, the value was the iterator function returned by GetMethod, and its final use was a call from GetIteratorFromMethod. Next, examine what processing occurs between those points. In the case of Array.from(), the output’s constructor runs, allowing arbitrary JavaScript code to execute. Even in a test environment where you cannot directly invoke the garbage collector, any interval during which JavaScript can run should be examined for possible allocations or deallocations.

Then, trace the reference paths to that value. If the original object holds the function as a property, the function may be reachable through it. However, if the function was created by a getter, the object that owns the getter does not necessarily retain ownership of its return value. Assuming “it’s safe because we rooted the input object” overlooks this distinction. Even if Rust’s JsObject remains in scope, we still had to verify that it does not automatically become part of the garbage‑collector’s root set.

In the test, we fix all conditions except the ones we want to protect. In this case, we kept the input object and the getter alive, and made only the returned function a temporary reference. We forced a garbage collection in the destination’s constructor and then verified the results up to subsequent calls. We check not only that the process didn’t crash, but also that the iterator was invoked exactly once and that the correct value was written to the correct output target. If we don’t first confirm that the test fails before the fix, the bug might be masked by a leftover value on the VM stack—just as happened with the initial JavaScript getter version.

By examining things in this order, we can narrow down what actually needs to be changed. Approaches such as halting garbage collection, moving the iterator call before the constructor, or writing the function back into the input object are unnecessary or would alter the intended behavior. What was actually required was to root the retrieved function only during the dangerous interval. Even if an exception interrupts execution, the registration is removed when Rooted is destroyed, so the retention period aligns with Rust’s control flow.

However, the fact that this procedure closed the current hole in Array.from() and that we could audit the engine’s overall reference retention are separate matters. Other built‑in functions may also have places where a JavaScript value is retained on the native side, then user code is called midway before the value is reused, but this article does not examine those cases. Moreover, the logs that captured a SIGSEGV in the vtable do not pinpoint exactly which garbage‑collection cycle reclaimed the memory. What we could confirm here is that the test reproducing the missing reference retention failed before the fix, and rooting the target function eliminated both the test failure and the Acid3 symptom.

This bug was in a different area from the previous Promise reference‑retention leak and the shared‑shape weak‑reference cache issue. Even if the crashes look similar, you have to trace step by step which values need to stay alive during which operations. Creating a regression test that can pinpoint the problem is more valuable than merely applying a one‑line fix. (source) (source)