[Bug] shapeCache LRU eviction disposes in-use ShapeTuples → "Object has already been disposed" → GPU device hang (regression introduced in 0.2.83)
Author: YuWenYang1Created Aug 6, 2026Updated Aug 18, 2026
Body
## Summary
Starting in **0.2.83**, generation reliably fails on a low-end integrated GPU with
`Error: Object has already been disposed`, which then takes down the entire page's
WebGPU device (`DXGI_ERROR_DEVICE_HUNG`). A page reload is not enough to recover —
the browser has to be fully restarted.
**0.2.82 is unaffected.** Same machine, same model, same weights, same prompt.
The trigger correlates with prompt length: short prompts work, and past roughly
120 tokens it fails every time.
I believe the cause is the `shapeCache` added in 0.2.83, whose LRU eviction callback
calls `dispose()` on cached `ShapeTuple` objects that callers may still be holding.
## Environment
| | |
|---|---|
| `@mlc-ai/web-llm` | **0.2.83, 0.2.84 fail** / 0.2.82 works |
| GPU | AMD Radeon(TM) Graphics (integrated), `VENDOR=0x1002 DEVICE=0x15e7` |
| Driver | `31.0.12042.2002` |
| OS / Browser | Windows 11, Chrome (`chrome://gpu` reports WebGPU: Hardware accelerated) |
| Models | `Qwen3-1.7B-q4f16_1-MLC`, `Qwen3-0.6B-q4f16_1-MLC`, `Qwen3.5-2B-q4f16_1-MLC` |
| Engine | `WebWorkerMLCEngine` (also reproduces with `ServiceWorkerMLCEngine`) |
## Reproduction
```js
import * as webllm from "@mlc-ai/web-llm";
const engine = new webllm.WebWorkerMLCEngine(
new Worker(new URL("./worker.js", import.meta.url), { type: "module" }),
);
await engine.reload("Qwen3-1.7B-q4f16_1-MLC", { context_window_size: 4096 });
// Works: prompts up to ~100 characters (~90 tokens)
// Fails: ~130 characters (~120 tokens) and above — every time
await engine.chat.completions.create({
messages: [{ role: "user", content: LONG_PROMPT }],
stream: true,
});
```
Result on 0.2.84, ramping prompt length within a **single** engine and a **single**
`reload()` (prompt length is the only variable):
```
20 chars ✓ 14.4s 40 chars ✓ 11.4s 60 chars ✓ 5.6s
80 chars ✓ 2.7s 100 chars ✓ 4.4s 130 chars ✗ Object has already been disposed
```
The exact same ramp on 0.2.82, same machine, same model:
```
100 ✓ 5.8s 130 ✓ 5.9s 160 ✓ 6.2s 200 ✓ 6.7s
240 ✓ 7.0s 280 ✓ 9.7s 327 ✓ 10.8s 500 ✓ 10.9s 800 ✓ 10.0s
```
Two things worth noting about the failing case:
* **Latency does not grow with prompt length** (the 20-char case is the *slowest*
at 14.4s). Decode happily emits hundreds of tokens. Only prefill breaks.
* It is **not** about the `system` role: `system` + 16 chars succeeds, while
327 chars stuffed into a single `user` message fails.
Once it fails, the GPU device is gone for that page:
```
ID3D12Device::GetDeviceRemovedReason failed with DXGI_ERROR_DEVICE_HUNG (0x887A0006)
Failed to execute 'requestDevice' on 'GPUAdapter': D3D12 create command queue failed
with DXGI_ERROR_DEVICE_REMOVED (0x887A0005)
```
## Bisect
I diffed the built `lib/index.js` from unpkg across versions:
| Version | occurrences of `shapeCache` | Published | Result |
|---|---|---|---|
| 0.2.80 | 0 | 2025-11-24 | works |
| 0.2.81 | 0 | 2026-02-17 | works |
| 0.2.82 | 0 | 2026-03-13 | **works** |
| **0.2.83** | **7** | 2026-04-29 | **fails** |
| 0.2.84 | 7 | 2026-05-27 | fails |
The cache appears in exactly the release where the failure appears.
(As a sanity check on the "is it really the library?" question: I also fetched the
deployed bundle from `chat.webllm.ai`, which works fine on this same machine, and
confirmed it contains **0** occurrences of `shapeCache` / `CacheState` / `LRUCache`.)
## Suspected cause
In 0.2.84's bundled TVM.js runtime:
```js
class CacheState {
constructor(shapeCacheSize = 256) {
this.shapeCache = new LRUCache(shapeCacheSize, (_key, value) => value.dispose());
}
// …
}
```
and the only consumer:
```js
makeShapeTuple(shape) {
const key = CacheState.computeShapeKey(shape);
return this.cacheState.shapeCache.get(key, () => {
const shapeArray = shape.map((value) => new Scalar(value, "int"));
const tuple = this.ctx.makeShapeTuple(...shapeArray);
// Detach from scope so the cached object survives across scopes.
this.detachFromCurrentScope(tuple);
return tuple;
});
}
```
Two properties combine badly here:
1. `makeShapeTuple()` **returns the cached object itself** to the caller — a borrowed
reference. The cache has no way to know whether a caller still holds it.
2. The tuple is deliberately **detached from scope management**, so the cache is its
sole owner and the eviction callback is the only thing that frees it.
So when a 257th distinct shape is requested, `LRUCache.get()` evicts the
least-recently-used entry and immediately `dispose()`s it — even if an earlier call
handed that same object to a caller that is still using it. The next use throws
`Object has already been disposed`, and on this driver the resulting bad dispatch
hangs the device rather than failing cleanly.
**The docstring on `CacheState` states the intended invariant, and it is the opposite
of what the code does:**
```
* - **shapeCache**: Caches TVM ShapeTuple objects keyed by dimension string.
* - Invalidation: Never. Shape tuples are immutable value objects that
* remain valid for the lifetime of the TVM instance.
```
"Invalidation: Never" — but a bounded LRU with a disposing eviction callback
invalidates as soon as it is full. I think the `dispose()` callback was added by
analogy with the `uniformCache` described just below it in the same comment (which
*does* need invalidation), and the mismatch was not caught because on faster GPUs
the window between "handed out" and "used" is short enough to rarely lose the race.
### Why prompt length is the trigger
Longer prefills exercise more *distinct* shapes (varying chunk/sequence dimensions),
so they churn through the 256-entry budget faster and start evicting while earlier
tuples are still live. Short prompts and the decode loop reuse the same handful of
shapes (`[1,32,128]` etc.) and never fill the cache — which matches the docstring's
own stated motivation. This also explains why the 0.6B model tolerates longer prompts
than the 1.7B one: fewer layers means fewer distinct shapes per forward pass.
This is the same class of bug as #571 (`[Fix][Grammar] Detach token table to prevent
disposing it`) — an object being freed while a live reference to it still exists.
## Suggested fix
The minimal change that matches the documented intent is to **stop disposing on
eviction**:
```js
// Shape tuples are immutable and tiny; the cache cannot know whether a caller
// still holds the borrowed reference it handed out.
this.shapeCache = new LRUCache(shapeCacheSize);
```
`CacheState.dispose()` already walks every cached object and disposes it at teardown,
so nothing is leaked for the lifetime of the TVM instance — which is exactly the
lifetime the docstring says these objects should have.
If bounded memory really is required, then `makeShapeTuple()` cannot keep handing out
borrowed references — it would need refcounting, or to return a copy.
Happy to test a patch on the affected hardware; this reproduces 100% of the time here.
## Workaround (for anyone hitting this)
Pin to the last good version — note the **exact** version, `^0.2.82` would resolve to
0.2.84 and reintroduce the bug:
```json
{ "dependencies": { "@mlc-ai/web-llm": "0.2.82" } }
```
The tradeoff is that models added to the built-in list in 0.2.83+ (Qwen3.5 among them)
aren't available on 0.2.82, so you're stuck on the older model lineup until this is
fixed. On 0.2.82 we run `Qwen3-1.7B-q4f16_1-MLC` with `context_window_size: 4096`
and prompts up to 800 characters with no failures.Source: mlc-ai/web-llm