Two commits landed on the ya-webadb upstream on 2026-10-02:
7eaade3—fix(credential-web): fix IndexedDB usage (#871)a5134b5—feat(webusb): handle NotFoundError in transferIn
The first one closes the regression we wrote up on2026-09-22
and flagged as still-open in our2026-09-28
Monday re-check. The second one is a five-line resilience patch for
the WebUSB transport’s transferIn loop, and the maintainer’s choice
of error name is worth a paragraph on its own. Both commits are onmain, neither has shipped as a tagged release yet.
This post is the read-through: how the IndexedDB fix differs from the
two patches we sketched in the Sep 22 writeup (the issue reporter’s
“resolve with request.result in oncomplete“ pattern, plus our
“drop the cache” alternative), why the generator-based fix that
actually shipped is a better answer than either, what the newclose() method on TangoKeyStorage is for, and how the WebUSBNotFoundError handler slots into the existing transfer-retry
state machine.
What the IndexedDB fix actually changed
7eaade3 is 17 files, +688/-111 lines, dominated by a rewrite oflibraries/adb-credential-web/src/storage/indexed-db/shared.ts. The
old createTransaction callback signature (tx) => T is replaced
with a generator-based one:
1 | // BEFORE (beta.3, broken) |
Call sites move from “return a Promise from a synchronous callback,
get yelled at” to “yield IDBRequest values from a generator, the
helper awaits them inside the transaction’s micro-task window.”v1.ts and v2.ts both flip to the generator pattern:
1 | // v1.ts (getAllKeysV1) |
The behavioral changes underneath the API churn are three:
createTransactionno longer rejects Promise returns. The
old guardif (result instanceof Promise) throw ...is gone;
the new generator-based interface doesn’t even have a synchronous
callback to reject. Bug #1 from the Sep 22 post is fixed by
removing the trapdoor instead of by patching the trapdoor.waitRequestnow refuses to operate inside a transaction.
The function rejects withError: Cannot wait for a request inside a transactionif it
detectsrequest.transaction !== null. The right primitive for
in-transaction waiting is the generator’swaitRequestHelper,
which yields anIDBRequestand re-enters the transaction’s
micro-task window ononsuccess. UsingwaitRequestinside a
transaction was anAbortErrorfactory — every queued micro-task
landed after the transaction had auto-committed, so the request
event handler fired against a stale handle. The new guard makes
that footgun impossible to load.- The cached
IDBDatabaseis no longer closed after each
operation.v2.tsdrops thetry { ... } finally { db.close() }
pattern fromsave()/load()/clear()and adds a newclose()method onTangoIndexedDbStorage. The cache survives
across operations; closing it is the consumer’s responsibility.
The third one is the interesting call. The Sep 22 post sketched two
options:
1 | // Option A: keep the per-operation close, drop the cache |
The shipped fix is Option B, plus a close() method so the
consumer can still free the underlying IndexedDB worker thread
when they’re done. The TangoKeyStorage interface instorage/type.ts now has an optional close():
1 | export interface TangoKeyStorage { |
…and all three concrete implementations (TangoIndexedDbStorage,TangoPasswordProtectedStorage, TangoPrfStorage) implement it,
plus AdbWebCryptoCredentialManager exposes it on the manager
level:
1 | // manager.ts (the wrapper) |
The AdbCredentialManager interface inlibraries/adb/src/daemon/auth/packet-processor.ts picks up the
same optional close() so adbDaemonAuthenticate() callers can
release the handle when they tear down.
The reasoning behind Option B over Option A is thatadbDaemonAuthenticate() is on the hot path — it runs every time
a connection opens — and Option A would re-open the database (and
pay the onupgradeneeded gating) on every reconnect. Option B
amortizes that cost, and the close() hook gives downstream code
an escape hatch. The implementation also adds a small race
fix: #openDatabasePromise is now reset to undefined insideclose() before the awaited db.close() resolves, so asave() racing with close() doesn’t accidentally hand back the
just-closed handle.
Why the generator pattern is the right answer
The Sep 22 post endorsed the issue reporter’s fix sketch: “returnIDBRequest from the callback, resolve with request.result inoncomplete.” That’s a 10-line change and it does fix the
synchronous-Promise bug. What it doesn’t fix is the second class
of footgun: a callback that does multiple operations on the
transaction. IndexedDB transactions auto-commit when control
returns to the event loop without a queued request. A callback
that wants to get() a value, branch on it, then put() a
different value can’t be expressed as “synchronously return anIDBRequest“ — it has to wait for the first request to settle,
and by then the transaction is gone.
The generator pattern solves that by collapsing “wait for this
request” into a single language primitive (yield request) and
having createTransaction drive the generator forward only when
the yielded request’s onsuccess fires — inside the
transaction’s micro-task window, which is the only window where
you can queue another request against the same transaction.
TypeScript’s Generator<IDBRequest<any>, T, unknown> type makes
this statically checkable: the yield expression has to be anIDBRequest, and the function’s eventual return type is T.
1 | // Inside waitRequestInTransaction |
The two e.preventDefault() / e.stopPropagation() calls on the
error path are the IndexedDB-spec-mandated incantation to keep
the transaction alive when a request errors. Without them, the
first error auto-aborts the transaction and every subsequent
queued request errors out with the same AbortError. With them,
the generator’s try { yield ... } catch (e) { ... } can decide
whether the error is fatal or recoverable.
The shipped pattern is more verbose than the issue reporter’s
sketch but it’s a strictly larger superset of what you can
express: the single-request “yield one request, return its
result” case is still one line, and you also get the
multi-request case for free. The cost is 181 lines of shared.ts
vs the 30-line original. That’s a fair trade — the bug class
the original opened (a synchronous callback rejecting Promises,
plus the auto-commit window leaking across multi-step operations)
goes away entirely.
What we still don’t know about PR #871
Three things the diff doesn’t tell us:
- No new test fixture exercises the multi-request transaction
path. The newshared.spec.tsadds 272 lines of test coverage,
but the public call sites inv1.tsandv2.tsare all
single-request generators. The “yieldget(), branch, yieldput()“ use case — the one that motivated the pattern — has
no fixture. It would be one for the test suite to add. - **The
onerrorpath’sstopPropagationonly fires when the
request’s ownonerroris wired up bywaitRequestInTransaction.
If a downstreamIDBObjectStore.put(...)rejects inside a
generator body without being yielded, the error bubbles to the
transaction’sonerror, and the transaction aborts. The
spec-correct behavior forIDBObjectStoreoperations that
throw synchronously is to abort, so this is fine — but it’s a
subtle distinction. The generator’scatchonly fires for
errors that happen during a yielded request’sonsuccess
handler. - The PR doesn’t bump the package version.
package.jsoninlibraries/adb-credential-webonly changes by the lockfile
bumps (+6/-6is the index lock churn, noversionfield
change in this diff). The next tagged release ofadb-credential-web
will ship the new code, but until that tag is cut, anyone pinning
to3.0.0-beta.3is on the broken tree. The Sep 22 post’s
workaround — pin3.0.0-beta.2or switch backends — is still
the right call until the next beta tag.
The WebUSB NotFoundError handler
The other commit, a5134b5,
is a five-line patch inlibraries/adb-daemon-webusb/src/device.ts around line 235 of
the AdbDaemonWebUsbConnection class. Inserted inside the
existing transferIn retry loop, which already handlesNetworkError (re-issues the transfer) and re-throws everything
else. The full diff is:
1 | if (isErrorName(e, "NetworkError")) { |
It’s inserted inside the existing transferIn retry loop, which
already handles NetworkError (re-issues the transfer) and
re-throws everything else. The new branch absorbsNotFoundError — which means “the device handle no longer exists
in the browser’s USB device set” — and resolves the in-flight
read with undefined instead of throwing.
The whatwg/usb issue link in the comment
(whatwg/usb#219)
is the spec thread where this was hashed: when a user revokes USB
permission mid-transfer (Chrome’s permission UI, the OS removing
the device, the user unplugging it from a hub), the in-flighttransferIn() promise rejects with NotFoundError on most
Chromium versions, regardless of whether the underlyingusbDevice is still in navigator.usb.getDevices(). The
previous behavior of “throw on any non-NetworkError“ means a
disconnect during a long-running shell stream surfaces as an
uncaught promise rejection in the consumer, not as a clean
“stream ended” signal. The patch turns the disconnect into the
same “stream ended” signal that a normal close would produce.
This is the same fix shape that the webrtc / WebSocket layers
have had for years — treat “the underlying resource went away”
as a normal end-of-stream, not an error. The WebUSB transport’s
disconnect detection was the last major consumer of
ya-webadb’s Readback/Writeback that didn’t have it.
Three implications for downstream consumers:
- Stream handlers can drop their
try { ... } catch (e) { if (e.name !== "NotFoundError") throw }boilerplate.
Before the patch, every consumer that wanted to gracefully
handle disconnects had to filterNotFoundErrorthemselves.
Now the transport does it. AReadableStream‘scancel()callback fires when the underlying source resolves
withundefined, and the consumer can clean up state from
there. - Disconnect detection latency drops to whatever the next
transferInwas scheduled to run. Before, a disconnect
would surface as the nexttransferIn‘s rejection; now it
surfaces the same way, just without throwing. There is no
“proactive” detection — if no transfer is queued, the
transport doesn’t notice. Thedisconnected: Promise<void>
onAdbSessionis still the canonical “did the device go
away” signal. - The fix is upstream-only. ya-webadb’s transport handles
this for every consumer; there’s no app-level patch needed.
If you’ve been writingif (e.name === "NotFoundError") returnin your own WebUSB code, you can delete it once you
upgrade.
What’s next on the ya-webadb release train
The two commits are on main but no 3.0.0-beta.4 tag has been
cut yet. The Sep 22 post anticipated the IndexedDB fix would land
“in 3.0.0-beta.4 (or RC)” and that’s still the working
expectation. The NotFoundError fix is independent enough that it
could ship in the same beta or get folded into a 3.0.0 RC
depending on what else the maintainer has queued.
For webadb.online specifically, the IndexedDB fix doesn’t change
our pinning calculus: we’re on TangoLocalStorage, notTangoIndexedDbStorage, and we pinned 3.0.0-beta.1 (notbeta.3) for unrelated reasons. What the fix does do is
unblock the option of pinning beta.3 (or whatever the next
tagged version is) without taking on the regression. TheNotFoundError patch is a clean improvement to the WebUSB
transport and a freebie if we ever do bump.
The webadb-roadmap post we wrote back in July flagged “WebUSB
disconnect UX” as a P3 follow-up; this commit is upstream-of-
that-work. The improvement we’d still want on top is proactive
disconnect detection (a Device.statechange listener that
cancels in-flight transfers when the device is unplugged), but
that’s a separate change.
What this means for webadb.online
No code change required. We’re still on TangoLocalStorage,
still pinned at 3.0.0-beta.1, and neither of these upstream
commits is reachable from our deployed bundle. The IndexedDB fix
removes one of the two reasons we’d been wary of bumping tobeta.3 (the other being the lack of field time on that beta
since release); the NotFoundError patch improves the resilience
of the WebUSB transport we do use, even if indirectly.
The takeaway for downstream ya-webadb integrators who were on
the IndexedDB path: when the next adb-credential-web tag
lands (presumably 3.0.0-beta.4), upgrade. The fix is
structurally different from the synchronous-Promise workaround
the issue reporter proposed, and it closes a class of bugs
(yielding non-IDBRequest values, multi-step transactions
auto-committing, error paths auto-aborting) that the workaround
would not have caught. If you’re on TangoLocalStorage like
we are, the upgrade is safe but optional; the new code paths
are unreachable from your call sites.
For the NotFoundError fix: if you’re shipping a downstream
app and you have if (e.name === "NotFoundError") return in
your WebUSB error handlers, you can delete it once you pull ina5134b5 or later. The transport now does the right thing on
its own.
We’ll keep watching main for the next beta tag. The Sep 22
“watch list” we wrote up — 7eaade3 closing the regression, a
new beta or RC, then a few weeks of field time — has now
checked the first box.