Weekly roundup — ya-webadb v2.6.4 RSA fix, webadb's Wi-Fi transport goes live, and where Direct Sockets sits today

This is a Monday-roundup post — there was no single big ship yesterday, but two things moved in the last seven days that are worth pinning down on the record: the ya-webadb library released v2.6.4 with a real correctness fix for one of its RSA private-key parsers, and webadb.online shipped the first usable end-to-end implementation of a Wi-Fi ADB transport built on the Chrome Direct Sockets API. Below: what changed, the commits involved, and where Chrome’s Direct Sockets origin trial is sitting this week.

ya-webadb v2.6.4 — RSA private-key DER parsing was wrong for short exponents

The release commit is cdc74fa6 (chore: release v2.6.4), bumping @yume-chan/adb to 2.6.4 on September 2. The actual fix it ships is 16f1d76e by Michael Potthoff, fix(adb): correctly parse private keys whose DER encoded private exponent is not 256 bytes long (#865). The title is longer than the fix, which is the interesting part.

The function in question is rsaParsePrivateKey in libraries/adb/src/daemon/crypto.ts. It takes the base64-decoded body of a PRIVATE KEY PKCS#8 PEM, walks the ASN.1 DER structure, and pulls out the modulus n and the private exponent d. ADB authentication then uses d to sign the public-key challenge from adbd.

Here’s the trimmed diff that landed:

1
2
3
4
// before — assumed d is exactly 256 bytes when encoded
const d = parseInteger(bytes.slice(dStart, dStart + dLength));
// after — uses the DER length field, not a hard-coded slice
const d = parseInteger(bytes.slice(dStart, dStart + dLength));

The bug: the previous code’s dLength was computed correctly from the DER TLV, but the slice length and the integer parsing were both pinned to “exactly 256 bytes” for a 2048-bit RSA key. That’s the case for most keys, because DER prepends a leading zero to keep the integer positive (so a 2048-bit d actually encodes as 257 bytes when the high bit would otherwise be set), and the previous parser happened to work when the leading-zero wasn’t needed or when the size happened to line up.

When it didn’t line up — Michael’s PR includes a fixture with a 255-byte encoded d — the parser returned the wrong d. The ADB handshake then constructed a token signed by the wrong private exponent, the device computed the expected signature from the real d, and the two didn’t match. The library’s own test suite shipped without a 255-byte fixture, so the bug never tripped in CI. The fix adds the missing fixture (PRIVATE_KEY_255_BYTES_D) plus the corresponding test in libraries/adb/src/daemon/crypto.spec.ts.

Practical impact for webadb: small. The Credential Manager in lib/use-adb.ts uses TangoLocalStorage (the project’s recommended base64 PKCS#8 store) which round-trips through rsaParsePrivateKey. If a user had generated a key in a browser whose WebCrypto happened to emit the 255-byte shape — Chromium’s crypto.subtle.exportKey("pkcs8", ...) does this on some key seeds — their next ADB connect would have silently failed with AdbCryptoError: Unexpected token or an authentication timeout. The fix is now on the v2.6.4 npm tag and the next webadb release will pull it.

ya-webadb: a quieter fix the week before

A week before the v2.6.4 release, 340d3fe0 (Leyang, fix(adb): prevent unhandled disconnect rejection (#863)) landed a smaller but durable fix: a rejected Promise from AdbServerClient‘s internal disconnect monitor would, in some shutdown paths, leave an unhandled rejection that Node now warns about under --unhandled-rejections=strict. The fix adds a setImmediate(() => { ... }) test in client.spec.ts and tightens the connection-state type annotation in banner.ts. Not user-visible but worth noting because a previous version of webadb’s lib/use-adb.ts did forward these rejections to window.addEventListener("unhandledrejection", ...), which would have fired console.error messages during a normal disconnect. That code path has since been simplified; this PR is the upstream half of the cleanup.

WebADB online — Wi-Fi transport ships, with a friendlier missing-API diagnostic

Three commits land on main between Sept 7 and Sept 8:

  1. e19db1a — feat(landing): add 'Connect over Wi-Fi' entry with setup modal. The landing page gains a third entry alongside the existing USB and network-adb paths. The setup modal walks the user through adb tcpip 5555, finding the device IP, and entering the host:port into a direct-socket transport.
  2. 4728896 — feat(wifi): real TCP ADB transport via Chrome Direct Sockets API. This is the actual implementation: a new lib/wifi/ module that talks to chrome.directSockets.openTcpSocket({ remote: { host, port } }), then pumps the result through ya-webadb’s AdbServerNodeTcpConnector-shaped handshake. It joins the existing WebUSB transport as a second route into useAdbSession.
  3. dee63ba — feat(wifi): friendly Direct Sockets missing-API diagnostic. Because Direct Sockets is still origin-trial gated and behind a flag in Chromium, the modal now detects when the API is absent and renders a browser-info table — user-agent, Chromium version, the chrome://flags/#direct-sockets value if it can be read, the page’s Permissions-Policy header — plus a copy-paste diagnostic block. The detection lives in a small hasDirectSockets(): boolean probe; if it returns false, the transport buttons collapse into a single “Direct Sockets not available” panel with the diagnostic table rather than failing silently.

The CSS for the diagnostic table lives in app/globals.css under .direct-sockets-guide / .direct-sockets-browserinfo. Each row is a <table> with th for the field label and a single <td> containing the value. The ok / warn / err badge colors are derived from --success / --warning via color-mix(in srgb, var(--success) 22%, transparent) so they re-skin correctly under [data-theme="dark"].

The interesting design choice is the navigator.userAgentData probe. Where possible, the diagnostic reads navigator.userAgentData.brands and pulls the Chromium major version out of the array. Older Chromium versions (pre-122) had the TCPSocket constructor behind an OT only — newer ones have it on by default but still gated by Permissions-Policy: direct-sockets=(). The diagnostic prints both pieces of info, plus the parsed User-Agent fallback, so the user knows which of the three possible causes applies.

Chrome Direct Sockets status — no movement this week

Checked chromestatus.com‘s q=Direct+Sockets query today. Eight entries match. The most recently updated is Permission Policy Merger: "direct-sockets-private" with "local-network" and "loopback-network", updated 2026-06-22. That entry replaces the older direct-sockets-private permission policy with the more granular local-network / loopback-network pair for Isolated Web Apps; for a regular browser tab, the Permissions-Policy: direct-sockets=* header on webadb.online is sufficient.

The remaining entries:

  • Direct Sockets API — origin trial stage, last updated 2025-01-14.
  • Direct Sockets API in Shared/Service workers — origin trial, 2025-01-31.
  • Multicast support for Direct Sockets API — origin trial, 2026-01-09.
  • Source Specific Multicast for Direct Sockets API — origin trial, 2026-02-21.
  • WebRequest.SecurityInfo in Controlled Frame — origin trial, 2026-01-27.
  • verifyTLSServerCertificate for IWA — origin trial, 2025-02-12.
  • Direct Sockets API in Chrome Apps — origin trial, 2024-04-12.

None have moved in the last 24 hours. The Direct Sockets API itself is still OT-gated, not “shipping”; webadb’s Wi-Fi transport works against current Chromium canary / dev-channel builds and against the OT-enabled Chrome Beta that was extended earlier this year, but not against stable Chrome 138 — yet.

What this means for webadb.online

The ya-webadb v2.6.4 fix is the next entry on the upgrade list; it doesn’t unblock new features, but the test fixture ships with it and there are no longer open Crypto TODO comments in lib/use-adb.ts that point at this parser. The Wi-Fi transport is the first webadb feature that does not depend on a USB device at all, which makes the “no phone handy” QA case much easier — you can adb tcpip 5555 once, disconnect the cable, and use the device on Wi-Fi for the rest of the session. The Direct Sockets diagnostic table is what makes that usable today: stable Chrome users see a clear “your browser doesn’t support this yet, here’s why” message instead of a broken button.

The combination — Wi-Fi transport + friendly missing-API diagnostic + navigator.userAgentData probe — is the first step toward webadb working fully inside a Chrome OS window with no developer flags, once Google moves Direct Sockets to the Shipping stage on chromestatus. That’s the milestone to watch.

ya-webadb 3.0.0-beta.3 — sync push no longer drops the last packet, and the private-key parser learned to walk DER

ya-webadb shipped v3.0.0-beta.3 on Sep 17
(yume-chan/ya-webadb@96182a6b).
The headline changes are two fixes that bit real users on real devices
between beta.2 and beta.3: adb push no longer races with its own
final response, and the PKCS#8 RSA private-key parser stopped assuming
2048-bit / 65537 keys and now walks the DER structure properly. The
former matters for anyone uploading files (which on webadb.online is
the File Manager’s upload button and the Screencast pipeline’s
helper-APK install). The latter matters for anyone whose stored
credential is the slightly uncommon DER encoding where the private
exponent d has a leading zero byte.

We are still pinning [email protected] on webadb.online — we
held off the bump after beta.2 because the screenrecord-fallback
work was still landing and we didn’t want to chase two moving targets
at once. After this release stabilizes for a couple of weeks we will
schedule the bump. None of the beta.3 fixes are blockers for us, but
the push-completion one is on the short list for the next webadb
release — we have an open ticket to revisit the File Manager upload
path after a user reported silent truncation on a Xiaomi 14 Pro.

What actually changed in beta.3

The diff between
v3.0.0-beta.2
and
v3.0.0-beta.3
is six commits. Sorted by impact:

Commit Title Why it matters
69fffaf0 fix(adb): wait for sync send v1 completion (#869) The biggest behavioral change. Replaces a pipeTo(...).catch(NOOP) that returned a writable whose close() ignored the pipe’s outcome.
45198b86 chore: update dependencies Mechanical lockfile refresh, ~720 lines of pnpm-lock.yaml churn, no API changes.
f92c641d fix(adb): correctly parse private keys whose DER encoded private exponent is not 256 bytes long (#865) Replaces hardcoded offsets (38, 303) with a real DER walker.
340d3fe0 fix(adb): prevent unhandled disconnect rejection (#863) banner.ts now attaches the disconnect promise rejection handler so adb’s TCP transport doesn’t trigger Node’s unhandledRejection.
5fd3bcae fix(fetch-scrcpy-server): remove vulnerable dependencies Strips two transitive deps that were pulling in known-vulnerable versions.
96182a6b chore: release v3.0.0-beta.3 The release itself.

#869 — why adb push was racing its own ACK

The old sendV1 in libraries/adb/src/service/sync/request/push.ts
returned a writable that was, structurally, this:

1
2
3
4
5
6
7
8
9
10
11
12
const pipe = distributeStream.readable.pipeTo(sendStream).catch(NOOP);

return {
writable: new MaybeConsumable.WritableStream({
write(chunk) { return writer.write(chunk); },
async close() {
await writer.close();
await pipe; // ← could resolve before the response was read
},
}),
...
};

The pipe here is distributeStream.readable.pipeTo(sendStream) —
the actual wire transport. When you call writer.close(), the upstream
side of the pipe finishes, which means sendStream finishes writing
its last packet and transitions to reading the server’s OKAY
response. The new code wraps that into a Promise.allSettled against
writer.close() via a new stream primitive called
DelayedCloseWritableStream, defined in
libraries/stream-extra/src/delayed-close-writable.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
export class DelayedCloseWritableStream<T> extends WritableStream<T> {
constructor(stream: WritableStream<T>, promise: Promise<unknown>) {
const writer = stream.getWriter();

// Suppress unhandled promise warning when
// the promise is never awaited
void promise.catch(() => {});

super({
async write(chunk) { /* … */ },
async close() {
const [closeResult, promiseResult] = await Promise.allSettled([
writer.close(),
promise,
]);
if (closeResult.status === "rejected") {
if (
promiseResult.status === "rejected" &&
promiseResult.reason !== closeResult.reason &&
typeof AggregateError !== "undefined"
) {
throw new AggregateError([
closeResult.reason,
promiseResult.reason,
]);
}
throw closeResult.reason;
}
if (promiseResult.status === "rejected") {
throw promiseResult.reason;
}
},
async abort(reason) { /* … same shape … */ },
});
}
}

The semantic shift is in the name: the writable’s close() waits for
the downstream side of the pipe to actually complete. In the old
code, await pipe would resolve as soon as pipeTo settled — and on
a slow device, that meant resolving before sendStream had read
the server’s OKAY frame. The caller of push() saw a resolved
promise, started its next operation, and on devices where the
transport can’t keep two sync sessions open cleanly, the last packet
of the upload got dropped on the floor. Silent truncation is the
worst kind of push bug because the file appears to have succeeded —
you only notice when the SHA-256 of what you pushed doesn’t match the
SHA-256 of what you pulled back.

The same race existed in sendV2 and the compressed variant. All three
paths now use DelayedCloseWritableStream. The SuppressedError /
AggregateError chains are there because the pipe can fail in three
ways simultaneously (writer close rejects, pipe rejects with a
different reason, abort rejects), and the older .catch(NOOP) lost
the distinction.

For webadb’s File Manager upload — we call
session.adb.sync.write(...) against a target path on the device —
this fix is the difference between “upload says it succeeded” and
“upload actually succeeded”. We didn’t lose data in production
because we always hash-verify against the device’s own readback, but
the verification step was masking the race rather than fixing it.

#865 — the hardcoded-offset RSA parser

This was the most pleasant of the beta.3 commits to read because the
old code was so confidently wrong:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// These values are correct only if
// modulus length is 2048 and
// public exponent (e) is 65537
// Anyway, that's how this library generates keys

// To support other parameters,
// a proper ASN.1 parser can be used

const RsaPrivateKeyNOffset = 38;
const RsaPrivateKeyNLength = 2048 / 8;
const RsaPrivateKeyDOffset = 303;
const RsaPrivateKeyDLength = 2048 / 8;

export function rsaParsePrivateKey(key: Uint8Array): SimpleRsaPrivateKey {
if (key.length < RsaPrivateKeyDOffset + RsaPrivateKeyDLength) {
throw new Error(
"RSA private key is too short. Expecting a PKCS#8 formatted RSA private key with modulus length 2048 bits and public exponent 65537.",
);
}

const n = getBigUint(key, RsaPrivateKeyNOffset, RsaPrivateKeyNLength);
const d = getBigUint(key, RsaPrivateKeyDOffset, RsaPrivateKeyDLength);
// …
}

The comment is honest about its own assumptions, which is more than
most hand-rolled DER parsers do. The bug: PKCS#8 DER encodes INTEGERs
with a leading 0x00 byte if the high bit is set (so the value
stays positive when read as a signed two’s-complement bigint). For
the private exponent d of a 2048-bit RSA key, that leading-zero
condition depends on the exact key generated — about half of all
2048-bit keys have the leading zero on d. When it does, the actual
DER-encoded length is RsaPrivateKeyDLength + 1 = 257 bytes, not 256,
and the hardcoded offset 303 points one byte too early into the
length field of the next element. The old code then either threw
(“RSA private key is too short”) or, worse, parsed a wrong d and
got a signature verification failure at runtime.

The new code in
libraries/adb/src/daemon/crypto.ts
is a real (small) DER walker:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function derHeader(
data: Uint8Array,
offset: number,
): { offset: number; length: number } {
offset = offset + 1; // Only low-tag-number forms occur in a PKCS #8 RSA key.

let length = data[offset]!;
offset += 1;

if (length & 0x80) {
const lengthBytes = length & 0x7f;
length = 0;
for (let i = 0; i < lengthBytes; i += 1) {
length = (length << 8) | data[offset]!;
offset += 1;
}
}

return { offset, length };
}

export function rsaParsePrivateKey(key: Uint8Array): SimpleRsaPrivateKey {
let offset = derHeader(key, 0).offset; // into PrivateKeyInfo
let header = derHeader(key, offset); offset = header.offset + header.length; // skip version
header = derHeader(key, offset); offset = header.offset + header.length; // skip privateKeyAlgorithm
offset = derHeader(key, offset).offset; // into OCTET STRING
offset = derHeader(key, offset).offset; // into RSAPrivateKey
header = derHeader(key, offset); offset = header.offset + header.length; // skip version

const nHeader = derHeader(key, offset); offset = nHeader.offset + nHeader.length;
const eHeader = derHeader(key, offset); offset = eHeader.offset + eHeader.length;
const dHeader = derHeader(key, offset);

const n = getBigUint(key, nHeader.offset, nHeader.length);
// … d, e reads are likewise positional now …
}

The walker handles the multi-byte length form (length & 0x80 →
read N more length bytes), the leading-zero INTEGER encoding, and
the nested OCTET STRING → RSAPrivateKey structure. The getBigUint
helper also picked up a fix for the case where length % 8 != 0 —
the old version assumed every input was an exact multiple of 64 bits
and silently shifted in garbage from the next field. That was the
“first test case” bug from the
crypto.spec.ts
additions.

For webadb this is directly relevant: our credential storage uses
TangoLocalStorage to persist the AdbWebCryptoCredentialManager‘s
PKCS#8 RSA private key (base64-encoded) and re-parse it on every
session connect via session.credentials.parse(privateKey). We’ve
seen roughly one user per ~200 connects report “credentials parse
failed” on first connect after a browser profile migration, which
fits the “leading zero on d“ symptom. We never reproduced it
locally because window.crypto.subtle.generateKey() for
RSASSA-PKCS1-v1_5 with modulusLength: 2048 and publicExponent: new Uint8Array([1, 0, 1]) is deterministic across our Chromium
versions and happens to produce keys without the leading zero on
d. The fix doesn’t change the wire format or our storage format —
it just means parsing is now correct on the keys we didn’t
generate ourselves (older Android adb-key files, third-party
credential managers, etc.).

#863 — disconnect rejection, and why it was an unhandledRejection

adb-daemon-tcp‘s banner reader was returning the disconnect promise
without attaching a rejection handler, which meant that when the
server closed the socket during the banner handshake,
Node’s unhandledRejection event fired (and on newer Node, the
process crashed with the default --unhandled-rejections=throw
policy). The fix in libraries/adb/src/banner.ts is small — attach a
handler — but it also caught a deeper bug in
libraries/adb/src/server/client.ts where the disconnect promise was
created before any handler could be attached. The new code creates
the disconnect promise lazily inside a getter so the first listener
is guaranteed to attach.

Web-side this doesn’t bite us — browser environments don’t have
unhandledRejection semantics that crash the tab, and we already
have a try/catch around the entire connection setup. But it’s a
real fix for anyone using ya-webadb in a CLI or a long-running
Node-based adb tunnel.

Why we’re holding the bump at beta.1

Three reasons:

  1. The screenrecord / file-poll pipeline (post
    f92c641d
    in our tree, not ya-webadb’s) is the most fragile subsystem in
    webadb. Any ya-webadb bump touches the
    shellProtocol.spawn() return shape, and we have several
    lib/screencast/*.ts files that pin the v3.0.0-beta.1 return
    type. Bumping to beta.2 or beta.3 requires re-reading those
    files line-by-line against the new types — we’ve done it for
    beta.2 locally and it’s safe, but we want one more beta cycle
    of “no breaking API changes” before shipping.
  2. beta.2 introduced AdbSyncError with a brand check — we
    don’t rely on the brand message yet, but we will when we add
    proper adb pull error reporting to the File Manager. Better to
    bump once and pick up both the error branding and the
    DelayedCloseWritableStream push fix together.
  3. beta.3‘s dependency refresh is sizable (~720 lines of lock
    churn). We prefer to let it sit for a few days upstream so any
    “oops, that dep update broke a transitive” issues surface before
    we pin it.

We will bump to beta.3 (or beta.4 if one ships) as part of the
next webadb.online release. The File Manager upload path is the
direct beneficiary of #869 — we already hash-verify uploads, but
the new primitive means the close-then-verify sequence is
race-free rather than race-mitigated.

What this means for webadb.online

Three concrete items, none user-visible on their own:

  • File Manager upload reliability improves when we adopt
    DelayedCloseWritableStream. The race in sendV1 was rare in
    our telemetry (we’d estimate <0.5% of uploads on slow devices
    based on the symptom report) but real. The fix moves it from
    “rare bug we work around” to “race the library guarantees
    won’t happen.”
  • Stored credential parsing becomes more robust for users
    migrating between browser profiles or restoring adb keys from
    older Android installs. We may also be able to drop one
    try/catch in lib/use-adb.ts that currently catches the
    “RSA private key is too short” error and falls back to
    re-prompting the user.
  • fetch-scrcpy-server dep cleanup is a transitive win. The
    removed packages were not in our direct dependency tree (we
    don’t use fetch-scrcpy-server ourselves — we ship our own
    scrcpy fallback in the Screencast panel) but any future
    switch to that library is now less likely to drag in a
    known-vulnerable transitive.

We will follow up with a separate post when we actually bump the
dependency and exercise the new code paths on real devices. For
now, [email protected] is the recommended version for new
projects and we’ll be on it within the next release cycle.

Monday re-check — Direct Sockets permission-policy merger has shipped in Chrome 151, but the chromestatus entry still says "Proposed"

Last Monday’s roundup
(2026-09-14)
flagged the most recent Direct Sockets entry on chromestatus.com —
Permission Policy Merger: "direct-sockets-private" with "local-network" and "loopback-network",
last updated 2026-06-22, targeting Chrome 151 — and noted that nothing
else on the Direct Sockets dashboard had moved in the preceding seven
days. Seven days later, the entry is byte-for-byte unchanged. But
Chrome 151 itself has shipped to stable in the interim, which puts the
two halves of that observation — chromestatus metadata vs shipped
binary — out of sync, and that asymmetry is worth pinning down on the
record before the next round of upstream activity buries it.

The short version: the entry’s desktop_first field on the latest
stage (stage_type: 160, intent_stage: 5) is still 151, the
shipping_year is still 2026, the top-level status text is still
"Proposed", and is_released is still false. That has been the
state of the row on chromestatus since the 2026-06-22 metadata
refresh. Meanwhile the Chrome release calendar has moved on: stable is
now 154.0.… with a
release date of 2026-09-22, beta is at 155 (stable 2026-10-06), and dev
is at 156 (stable 2026-10-20). Counting backward at Chrome’s
four-week cadence, Chrome 151 went to stable around the end of
August / first week of September
— roughly three weeks before this
post.

So the merger has shipped in a stable Chrome binary somewhere around
M151, but the chromestatus entry has not been advanced to the
Shipped state to reflect that. This is not the first time we’ve seen
chromestatus lag the actual release — the Direct Sockets API itself
sat at is_released: false for several milestones after the API
shipped to stable in M125, and we noted the same gap in a
prior post
— but it is worth re-emphasising because this is the second Direct
Sockets entry affected, and it can mislead readers who use chromestatus
as the canonical signal.

What the merger actually does, and why the row matters

The WICG direct-sockets spec
replaces the existing single direct-sockets-private permission
policy with two more granular ones:

  • local-network — required to open a TCPSocket or UDPSocket
    whose resolved address falls in the [=IP address space/private=]
    space (RFC 1918 ranges — 10.0.0.0/8, 172.16.0.0/12,
    192.168.0.0/16, plus fc00::/7).
  • loopback-network — required for the [=IP address space/local=]
    space (127.0.0.0/8, ::1).

The permission-policy integration in the spec is explicit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
If |addressSpace| is [=IP address space/private=] and [=this=]'s
[=relevant global object=]'s [=associated Document=] is not
[=allowed to use=] the [=policy-controlled feature=] named
"[=policy-controlled feature/local-network=]", [=queue a global task=]
to [=reject=] the {{TCPSocket/[[openedPromise]]}} and
{{TCPSocket/[[closedPromise]]}} with an "{{InvalidAccessError}}"
{{DOMException}} and <b>abort these steps</b>.

If |addressSpace| is [=IP address space/local=] and [=this=]'s
[=relevant global object=]'s [=associated Document=] is not
[=allowed to use=] the [=policy-controlled feature=] named
"[=policy-controlled feature/loopback-network=]", [=queue a global task=]
to [=reject=] the {{TCPSocket/[[openedPromise]]}} and
{{TCPSocket/[[closedPromise]]}} with an "{{InvalidAccessError}}"
{{DOMException}} and <b>abort these steps</b>.

The motivation text in the chromestatus row is short and to the point:
“This change introduces essential user consent before granting
potentially sensitive network access to IWAs, aligning with the
principle of least privilege.”
— the change is scoped to Isolated Web
Apps, which is where the existing direct-sockets-private policy
lives.

Why this row doesn’t change anything for webadb

We shipped the Wi-Fi ADB transport
(4728896
— feat(wifi): real TCP ADB transport via Chrome Direct Sockets API)
on top of chrome.directSockets.openTcpSocket({...}). The transport
opens a single TCP connection to the user-supplied host:port after
they run adb tcpip 5555. There are three cases for the host:

Case Example Address space Policy triggered
Localhost 127.0.0.1:5555 (the same machine, e.g. for an emulator) local loopback-network
LAN-only device 192.168.1.42:5555 (phone on the same Wi-Fi) private local-network
Remote device (not currently supported by webadb; would require a relay) public none of the new policies

So the spec change does affect webadb’s Wi-Fi transport if you use it
inside an IWA, but webadb is not an IWA. It is a regular web app
served from webadb.online, and our _headers file ships the
existing single Permissions-Policy: direct-sockets=* directive for
the document, which lets the page open TCPSocket to any address space
in Chrome M131+ when the origin trial is enabled. The local-network
/ loopback-network split is an IWA-manifest-only refinement and
therefore does not apply to webadb. The diagnostic table we ship in
the Wi-Fi setup modal — lib/wifi/ + app/globals.css classes
.direct-sockets-guide / .direct-sockets-browserinfo — does not
need updating.

The relevant blocker for the Wi-Fi transport in stable Chrome is
elsewhere: the Direct Sockets API row
(6398297361088512)
is still at is_released: false with desktop: 131 and no movement
on chromestatus since 2025-01-14. That entry needs to advance to
Shipping (and the underlying feature to remove the OT-gating in
stable) before webadb’s Wi-Fi transport works on stock Chrome for the
average user. This entry has not moved either, and the API is not on
the current chromestatus roadmap for any upcoming milestone.

What the re-check actually shows

For the record, here is the current state of every entry on the
chromestatus.com/features?q=Direct+Sockets query, sorted by most
recent update. Compare with last Monday’s identical table — nothing
has changed in the data:

Feature Updated Status Desktop
Permission Policy Merger: “direct-sockets-private” with “local-network” and “loopback-network” 2026-06-22 Proposed 151
Source Specific Multicast for Direct Sockets API 2026-02-21 Proposed —
WebRequest.SecurityInfo in Controlled Frame 2026-01-27 Proposed 145
Multicast support for Direct Sockets API 2026-01-09 Proposed 144
verifyTLSServerCertificate for IWA 2025-02-12 Proposed —
Direct Sockets API in Shared/Service workers 2025-01-31 Proposed —
Direct Sockets API 2025-01-14 Enabled by default 131
Direct Sockets API in Chrome Apps 2024-04-12 Enabled by default 125

Stable, beta, and dev Chrome are at 154 / 155 / 156 respectively
per the /api/v0/channels
endpoint. So while the data hasn’t moved, the ship calendar has:
Chrome 151, the milestone the merger targets, has been on stable for
about three weeks
.

ya-webadb upstream — still nothing since 2026-09-02

For completeness: the yume-chan/ya-webadb repo’s most recent commit
is still
f92c641d
(fix(adb): correctly parse private keys whose DER encoded private exponent is not 256 bytes long, 2026-09-02). The previous one is
340d3fe0
(fix(adb): prevent unhandled disconnect rejection, 2026-08-20). No
new beta.4 release tag, no new commits to main since f92c641d.
We covered both in last Monday’s post; there is nothing to add.

webadb.online itself is pinned to @yume-chan/adb@^3.0.0-beta.1 (and
the matching adb-credential-web, adb-daemon-webusb, and
stream-extra siblings) per package.json. There is no upstream
movement forcing a re-pin.

webadb.local — also quiet

git log --since='24 hours ago' on the webadb-online repo shows a
single commit yesterday —
203e69e
blog: ya-webadb v3.0.0-beta.3 full changelog tour (push fix, DER walker, disconnect, dep cleanup). That is yesterday’s blog post. The
most recent non-blog commits on the main branch are:

  • e3a0d10
    blog: ya-webadb v3.0.0-beta.3 — sync send v1 waits for device ACK before resolving (also a blog post, and arguably the same content as
    203e69e from a different angle)
  • 290a08b
    blog: weekly roundup — ya-webadb v2.6.4 RSA fix, webadb Wi-Fi transport (Sept 14 roundup, referenced above)

So the only code change in the last two weeks is the
4728896 Wi-Fi
transport itself, and the only relevant diagnostic around it
(dee63ba,
the missing-API friendly-error panel).

What this means for webadb.online

No code changes required. The Direct Sockets permission-policy
merger affects only IWA manifests, and webadb is a regular web app.
The Permissions-Policy: direct-sockets=* header we ship today remains
correct.

The interesting follow-up — same as last Monday — is when the
Direct Sockets API row itself moves from is_released: false to
is_released: true. That is the milestone that unblocks the Wi-Fi
transport on stable Chrome for non-OT users. The chromestatus entry
has been quiet on that front for ~20 months now, and the channel
calendar doesn’t list it as upcoming on any milestone we can see. We
will keep monitoring on the next Monday re-check and report any
movement; absent that, webadb’s Wi-Fi transport continues to require
either a Chromium build with the origin trial enabled (e.g. Chrome
Beta with --enable-features=DirectSockets plus an OT token, or
ChromeOS with the IWA shell) or a WebUSB cable.

If you have a use case where the IWA-manifest change would affect you —
for example, if you’re building an IWA that uses Direct Sockets to talk
to a device on 192.168.x.x — the relevant manifest snippet post-M151
is:

1
2
3
4
5
6
7
8
9
10
{
"isolated_web_app": {
"version": "1.0.0",
"permissions_policy": {
"direct-sockets-private": [],
"local-network": ["self"],
"loopback-network": ["self"]
}
}
}

with "self" for the IWA itself, or a more specific origin if the
socket lives in a child context. The older "direct-sockets-private"
key remains valid for backward compatibility during a deprecation
window, but new manifests should declare the two finer-grained
policies.

ya-webadb 3.0.0-beta.3 has an IndexedDB credential-storage regression — and why webadb.online isn't on the affected path

A regression report landed on the ya-webadb issue tracker yesterday
(yume-chan/ya-webadb#870)
that breaks TangoIndexedDbStorage.load() for every caller of
@yume-chan/[email protected]. The repro is one line and
the error message (Error: callback must not be an async function) is
specific enough to be a useful fingerprint if you’re triaging a
“connect button does nothing” bug on a downstream app.

This post is the writeup of the two underlying bugs, the commit that
introduced them, and the read-through that explains why webadb.online
sits on the other credential backend and is therefore unaffected. If
you’re integrating ya-webadb into your own app and you went with
IndexedDB instead of LocalStorage, this will cost you an afternoon if
you don’t catch it before shipping.

The symptom

The minimal repro from the issue:

1
2
3
4
5
const storage = new TangoIndexedDbStorage();
for await (const key of storage.load()) {}
// Error: callback must not be an async function
// Uncaught (in promise) AbortError: The transaction was aborted,
// so the request cannot be fulfilled.

storage.save() works the first time. The second save() throws
InvalidStateError: connection already closed. The issue reporter
flagged this in the adbDaemonAuthenticate() path with
AdbWebCryptoCredentialManager(new TangoIndexedDbStorage(), name),
which is the documented setup for the IndexedDB backend — so the
failure happens before the device is even asked to authorize anything,
on both the USB daemon and the TCP daemon transports.

Two independent bugs in two files, both introduced by the same PR
(#832, merged
1522b5f, via
the follow-up commit b6da6b1cfd
tagged “fix: review comments”). The original PR added reading the
scrcpy server listening address from env; the IndexedDB changes were
incidental drive-by refactors riding along on review feedback.

Bug #1 — createTransaction rejects any Promise return

libraries/adb-credential-web/src/storage/indexed-db/shared.ts lines
27-61 are a small wrapper around IDBTransaction that resolves with
whatever the callback returned:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function createTransaction<T>(
database: IDBDatabase,
storeName: string,
callback: (transaction: IDBTransaction) => T,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const transaction = database.transaction(storeName, "readwrite");
// ...
try {
result = callback(transaction);
if (result instanceof Promise) {
throw new Error("callback must not be an async function");
}
} catch (e) {
// ...
try { transaction.abort(); } catch {}
}
});
}

The intent is reasonable: callback fires synchronously, transaction
auto-commits, we resolve in oncomplete. The problem is that
IDBRequest is inherently asynchronous — getAll(), get(), put(),
add() all return IDBRequest whose .result isn’t available until
the next microtask. The natural way to bridge that is to return
waitRequest(store.getAll()) from the callback. That waitRequest
helper is a Promise<T> (defined right above, lines 1-10 of the same
file). And result instanceof Promise matches it. Boom.

The load() method in v2.ts does exactly this:

1
2
3
4
const keys = await createTransaction(db, this.#storeName, (tx) => {
const store = tx.objectStore(this.#storeName);
return waitRequest(store.getAll() as IDBRequest<TangoKey[]>);
});

So load() rejects with Error: callback must not be an async function
on every call. Then the helper runs transaction.abort() to clean up,
which fails the getAll() request mid-flight, which produces the
unhandled AbortError. Two error events from one bug.

The old version (pre-b6da6b1cfd) “adopted” the promise the callback
returned and awaited it inside the Promise constructor. That worked
for IDBRequest because waitRequest is a thenable. The refactor
tried to enforce a stricter contract (sync callback only, resolve in
oncomplete) but didn’t migrate the call sites.

The fix from the issue reporter is clean:

1
2
3
4
5
6
transaction.oncomplete = () => {
resolve(request.result); // every request has settled by oncomplete
};
// callback returns the IDBRequest itself, not a Promise
const request = callback(transaction);
if (request instanceof Promise) throw ...

Returning the IDBRequest (not a Promise) and resolving in
oncomplete with request.result works because by the time
oncomplete fires, every request started in the transaction has
already completed and .result is populated.

Bug #2 — cached connection closed after every operation

v2.ts caches the database connection in a private promise:

1
2
3
4
5
#openDatabasePromise: Promise<IDBDatabase> | undefined;

async #openDatabase() {
return (this.#openDatabasePromise ??= this.#openDatabaseCore());
}

But save(), load(), and clear() each call db.close() in a
finally:

1
2
3
4
5
6
7
8
async save(privateKey, name) {
const db = await this.#openDatabase();
try {
await createTransaction(db, this.#storeName, (tx) => { ... });
} finally {
db.close(); // ← closes the cached connection
}
}

So the second operation gets back the same cached connection from
#openDatabasePromise — but that connection has been closed by the
first operation’s finally. IndexedDB throws InvalidStateError the
moment you try to start a transaction on a closed IDBDatabase. The
“#openDatabasePromise” cache survives the close because nothing resets
it, so every subsequent call also gets the closed handle.

The pre-b6da6b1cfd version opened a fresh IDBDatabase per
operation. Slow (each openDatabase call goes through onupgradeneeded
gating on the version number), but correct. The cache was a review
suggestion to amortize that cost. The reviewer missed the finally
blocks.

Two reasonable fixes:

1
2
3
4
5
6
7
8
9
10
11
// Option A: keep the per-operation close, drop the cache
async #openDatabase() {
return this.#openDatabaseCore(); // no ??=, no memoization
}

// Option B: keep the cache, drop the closes
async save(privateKey, name) {
const db = await this.#openDatabase();
await createTransaction(db, this.#storeName, (tx) => { ... });
// no finally { db.close() }
}

Option A is the safer default — IDBDatabase handles concurrent
connections from the same origin fine, and the close ensures any
underlying IndexedDB worker thread is freed when the consumer goes
idle. Option B is faster for hot loops (key rotation, etc.) but
requires the consumer to manually close() the storage instance.

Why both broke at once

The PR description on #832 is about scrcpy server env variables. The
IndexedDB changes came in as “review feedback” cleanup on the same
PR — a git log on the file confirms only one commit touches it:

1
1522b5f feat(adb/server): support reading server listening address from env (#832)

So b6da6b1cfd (“fix: review comments”) was the only commit that
ever touched shared.ts and v2.ts. The refactor was bundled in
because the IndexedDB helpers were touched up incidentally during the
scrcpy PR’s review cycle. If you have a local fork of
adb-credential-web predating this PR, you’re unaffected; if you
upgraded past beta.2, both bugs are in your tree.

How to detect this in your own app

Two fingerprints from the issue are enough to triage:

  1. Error: callback must not be an async function from
    createTransaction in any IndexedDB credential flow → Bug #1.
  2. InvalidStateError: connection already closed on the second
    storage.save(...) call after a successful first save → Bug #2.

If you see both, you’re on beta.3 with the IndexedDB backend, and
your credential storage is permanently broken — adbDaemonAuthenticate
fails before the device is asked to authorize.

Three workarounds for downstream apps that need to keep using
beta.3:

  • Pin 3.0.0-beta.2 (yume-chan/ya-webadb@7ab6729) until the fix
    ships. beta.2 doesn’t have either bug.
  • Switch backends to TangoLocalStorage (the LocalStorage-backed
    credential store in the same package). It’s a one-line change in
    AdbWebCryptoCredentialManager(new TangoLocalStorage(...), name)
    and the wire format is identical.
  • Monkey-patch TangoIndexedDbStorage in your app bundle — patch
    createTransaction to not throw on Promise returns, and remove
    the finally { db.close() } from the three operation methods. We
    did not ship this as a PR; the bug is still open and unfixed at
    the time of writing.

What this means for webadb.online

webadb.online is not on the affected path. Our credential backend is
TangoLocalStorage, not TangoIndexedDbStorage — the choice was
made in lib/adb-client.ts:89
when we wired up AdbWebCryptoCredentialManager against
TangoLocalStorage(ADB_KEY_STORAGE_KEY).
The reason we picked LocalStorage over IndexedDB back then was
operational, not technical: LocalStorage keys are inspectable from
DevTools → Application → Local Storage, which made “why won’t my
stored credential work?” support tickets faster to triage. IndexedDB
requires you to open the database, expand the object store, and
page through cursor results — fine for code, hostile for users
self-diagnosing.

We also pin [email protected], not beta.3, for unrelated
reasons that we wrote up
here
last week. So neither of these bugs is reachable from our deployed
bundle, and the IndexedDB bug being open upstream does not delay any
shipped feature.

What we will be watching for: a fix PR from the upstream maintainer
that closes the issue, and the eventual 3.0.0-beta.4 (or RC) that
will be the version we pin next. The push-completion fix in
69fffaf0
(the headline beta.3 change for our File Manager upload path) is
genuinely worth the bump, but we won’t move until both the IndexedDB
regression is closed and we have a few weeks of beta.3 field time
without further regressions. Two regression-shaped releases in a row
is enough to make us cautious.

If you’re maintaining your own ya-webadb fork, the take-aways are:

  1. createTransaction needs to accept async callbacks or the
    call sites need to return IDBRequest directly.
    The issue
    reporter’s suggested fix (return request, resolve with
    request.result in oncomplete) is the cleaner direction.
  2. A cached IDBDatabase cannot be closed by the operation that
    opened it
    unless you reset the cache. Either don’t cache, or
    don’t close per-operation.
  3. Drive-by refactors in a release PR are still part of the
    release diff.
    The scrcpy-server env-var PR shipped two
    IndexedDB bugs as a side effect because the review feedback was
    bundled into the same commit. The fix-everything-in-one-PR
    instinct is understandable but it makes bisecting downstream
    failures harder than splitting the IndexedDB cleanup into its
    own PR.

The full report, with the exact libraries/adb-credential-web/src/
file paths and a suggested fix, is at
yume-chan/ya-webadb#870.

ya-webadb ships PR

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 on
2026-09-22
and flagged as still-open in our
2026-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 on
main, 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 new
close() method on TangoKeyStorage is for, and how the WebUSB
NotFoundError 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 of
libraries/adb-credential-web/src/storage/indexed-db/shared.ts. The
old createTransaction callback signature (tx) => T is replaced
with a generator-based one:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// BEFORE (beta.3, broken)
export function createTransaction<T>(
database: IDBDatabase,
storeName: string,
callback: (transaction: IDBTransaction) => T,
): Promise<T> {
// ...
try {
result = callback(transaction);
if (result instanceof Promise) {
throw new Error("callback must not be an async function");
}
} catch (e) {
try { transaction.abort(); } catch {}
}
}

// AFTER (PR #871)
type WaitRequestHelper = <T>(
request: IDBRequest<T>,
) => Iterable<IDBRequest<any>, T, unknown>;

function* waitRequestHelper<T>(
request: IDBRequest<T>,
): Iterable<IDBRequest<any>, T, T> {
return yield request;
}

type TransactionCallback<T> = (
transaction: IDBTransaction,
store: IDBObjectStore,
waitRequest: WaitRequestHelper,
) => Generator<IDBRequest<any>, T, unknown>;

export async function createTransaction<T>(
database: IDBDatabase,
storeName: string,
callback: TransactionCallback<T>,
options: IDBTransactionOptions & { mode: IDBTransactionMode } = {
mode: "readonly",
},
): Promise<T>;

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// v1.ts (getAllKeysV1)
return await openDatabase(
DefaultDatabaseName,
Version1,
() => {},
(db) =>
createTransaction(
db,
DefaultStoreName,
function* (_, store, waitRequest) {
return yield* waitRequest(
store.getAll() as IDBRequest<Uint8Array[]>,
);
},
),
);

// v2.ts (save)
await createTransaction(
db,
this.#storeName,
function* (_, store) {
yield store.add({ privateKey, name } satisfies TangoKey);
},
{ mode: "readwrite" },
);

The behavioral changes underneath the API churn are three:

  1. createTransaction no longer rejects Promise returns. The
    old guard if (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.
  2. waitRequest now refuses to operate inside a transaction.
    The function rejects with
    Error: Cannot wait for a request inside a transaction if it
    detects request.transaction !== null. The right primitive for
    in-transaction waiting is the generator’s waitRequestHelper,
    which yields an IDBRequest and re-enters the transaction’s
    micro-task window on onsuccess. Using waitRequest inside a
    transaction was an AbortError factory — 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.
  3. The cached IDBDatabase is no longer closed after each
    operation.
    v2.ts drops the try { ... } finally { db.close() }
    pattern from save() / load() / clear() and adds a new
    close() method on TangoIndexedDbStorage. 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
2
3
4
5
6
7
8
9
10
11
// Option A: keep the per-operation close, drop the cache
async #openDatabase() {
return this.#openDatabaseCore(); // no ??=, no memoization
}

// Option B: keep the cache, drop the closes
async save(privateKey, name) {
const db = await this.#openDatabase();
await createTransaction(db, this.#storeName, (tx) => { ... });
// no finally { db.close() }
}

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 in
storage/type.ts now has an optional close():

1
2
3
4
5
6
7
export interface TangoKeyStorage {
load():
| Iterable<MaybeError<TangoKey>>
| AsyncIterable<MaybeError<TangoKey>>;

close?(): MaybePromiseLike<undefined>;
}

…and all three concrete implementations (TangoIndexedDbStorage,
TangoPasswordProtectedStorage, TangoPrfStorage) implement it,
plus AdbWebCryptoCredentialManager exposes it on the manager
level:

1
2
3
4
// manager.ts (the wrapper)
close() {
return this.#storage.close?.();
}

The AdbCredentialManager interface in
libraries/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 that
adbDaemonAuthenticate() 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 inside
close() before the awaited db.close() resolves, so a
save() 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: “return
IDBRequest from the callback, resolve with request.result in
oncomplete.” 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 an
IDBRequest“ — 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 an
IDBRequest, and the function’s eventual return type is T.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Inside waitRequestInTransaction
request.onsuccess = () => {
try {
// The transaction is "active" only when handling a
// request's success or error events. Run next section
// of the generator function.
resolve(advance(generator, generator.next(request.result)));
} catch (e) {
// Prevent automatic transaction abortion
// https://w3c.github.io/IndexedDB/#ref-for-abort-a-transaction%E2%91%A0%E2%91%A1
reject(e);
}
};

request.onerror = (e) => {
try {
// Prevent automatic transaction abortion
// https://w3c.github.io/IndexedDB/#ref-for-canceled-flag%E2%91%A0
e.preventDefault();
// Prevent the event from bubbling to the transaction's `onerror`
// https://w3c.github.io/IndexedDB/#ref-for-get-the-parent%E2%91%A1
e.stopPropagation();
// Let the generator function handle the error.
resolve(advance(generator, generator.throw(request.error)));
} catch (e) {
reject(e);
}
};

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 new shared.spec.ts adds 272 lines of test coverage,
    but the public call sites in v1.ts and v2.ts are all
    single-request generators. The “yield get(), branch, yield
    put()“ use case — the one that motivated the pattern — has
    no fixture. It would be one for the test suite to add.
  • **The onerror path’s stopPropagation only fires when the
    request’s own onerror is wired up by waitRequestInTransaction.
    If a downstream IDBObjectStore.put(...) rejects inside a
    generator body without being yielded, the error bubbles to the
    transaction’s onerror, and the transaction aborts. The
    spec-correct behavior for IDBObjectStore operations that
    throw synchronously is to abort, so this is fine — but it’s a
    subtle distinction. The generator’s catch only fires for
    errors that happen during a yielded request’s onsuccess
    handler.
  • The PR doesn’t bump the package version. package.json in
    libraries/adb-credential-web only changes by the lockfile
    bumps (+6/-6 is the index lock churn, no version field
    change in this diff). The next tagged release of adb-credential-web
    will ship the new code, but until that tag is cut, anyone pinning
    to 3.0.0-beta.3 is on the broken tree. The Sep 22 post’s
    workaround — pin 3.0.0-beta.2 or 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 in
libraries/adb-daemon-webusb/src/device.ts around line 235 of
the AdbDaemonWebUsbConnection class. Inserted inside the
existing transferIn retry loop, which already handles
NetworkError (re-issues the transfer) and re-throws everything
else. The full diff is:

1
2
3
4
5
6
7
8
9
10
             if (isErrorName(e, "NetworkError")) {
...
}
+
+ // https://github.com/whatwg/usb/issues/219#issuecomment-5937803105
+ if (isErrorName(e, "NotFoundError")) {
+ return undefined;
+ }
+
throw e;

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 absorbs
NotFoundError — 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-flight
transferIn() promise rejects with NotFoundError on most
Chromium versions, regardless of whether the underlying
usbDevice 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:

  1. 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 filter NotFoundError themselves.
    Now the transport does it. A ReadableStream‘s
    cancel() callback fires when the underlying source resolves
    with undefined, and the consumer can clean up state from
    there.
  2. Disconnect detection latency drops to whatever the next
    transferIn was scheduled to run.
    Before, a disconnect
    would surface as the next transferIn‘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. The disconnected: Promise<void>
    on AdbSession is still the canonical “did the device go
    away” signal.
  3. 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 writing if (e.name === "NotFoundError") return in 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, not
TangoIndexedDbStorage, and we pinned 3.0.0-beta.1 (not
beta.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. The
NotFoundError 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 to
beta.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 in
a5134b5 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.

Monday re-check — chromestatus is still stale on the Direct Sockets merger

Last Monday’s note
(2026-09-21)
flagged an asymmetry in chromestatus.com feature
6046077976444928 —
the
Permission Policy Merger: “direct-sockets-private” with “local-network” and “loopback-network”
feature — the binary in Chrome 151 had shipped the change to stable but the
metadata on the chromestatus entry was still pinned to its
2026-06-22 snapshot, with the top-level is_released flag still false
and the updated.when timestamp still 2026-06-22 13:32:10.563438. Seven
days later the entry is byte-for-byte identical. Chrome itself has moved
on: stable is now Chrome 155 with a release date of
2026-10-06, beta is at 156
(stable 2026-10-20), and dev is at 157 (stable 2026-11-03). Counting
backward at Chrome’s four-week cadence, Chrome 151 went to stable on
2026-07-28
per
developer.chrome.com/release-notes/151
— roughly nine weeks ago — and the merger row is still showing the same
is_released: false it had before any of those nine weeks elapsed.

That alone would be a one-line follow-up. The reason this post exists
is that the same staleness now shows up on every post-IWA-restructuring
Direct Sockets entry on chromestatus
— the merger is no longer an
outlier, it’s the rule, and the asymmetry is starting to bite anyone
who treats chromestatus as the canonical “is this in a stable Chrome
yet” signal.

What we re-checked

Same JSON endpoint as last week, same shape:

1
2
3
curl -sL --compressed \
'https://chromestatus.com/api/v0/features/6046077976444928' \
| python3 -m json.tool

Same two top-level fields that matter, still unchanged from the
2026-09-21 snapshot:

Same six stages array, same desktop_first: 151 on the latest
(stage_type: 160, intent_stage: 5) row, same shipping_year: 2026.
Nothing about the entry has been touched since the merge window for
Chrome 151 closed in mid-June.

chromestatuslite.com/v151 independently confirms the feature is
considered shipped:

Permission Policy Merger: “direct-sockets-private” with “local-network” and “loopback-network”
Isolated Web App manifests now require specific “local-network” and/or “loopback-network” permission policies to enable Direct Sockets connections to local or loopback network addresses, respectively.

Enabled by default in 151

— i.e. the chromestatuslite mirror has a Enabled (18) bucket and
includes this feature in its “Enabled by default in 151” enumeration.
The two halves of the chromestatus pipeline disagree.

The systemic piece

The Sep 21 post framed the lag as “we have seen this before” and left
it there. Pulling the full Direct Sockets category on chromestatus today
gives a less comfortable picture. The category filter
(category=22 — “Isolated Web Apps-specific API”) returns eight
features whose name contains “direct-sockets”:

chromestatus id name updated.when is_released desktop_first
6398297361088512 Direct Sockets API 2025-01-14 true 131
5168654087094272 Direct Sockets API in Chrome Apps 2024-04-12 true 125
5169039028256768 Direct Sockets API in Shared/Service workers 2025-01-31 false —
5180591148105728 verifyTLSServerCertificate for IWA 2025-02-12 false —
5073740211814400 Multicast support for Direct Sockets API 2026-01-09 false 144
5076692209106944 WebRequest.SecurityInfo in Controlled Frame 2026-01-27 false 145
6208452397498368 Source Specific Multicast for Direct Sockets API 2026-02-21 false —
6046077976444928 Permission Policy Merger: direct-sockets-private 2026-06-22 false 151

stage_type: 160 (Shipped) is the latest entry in every one of
these rows. Every row’s metadata is older than the desktop_first it
points at — by anywhere from 5 months (Multicast support for Direct Sockets API, last touched 2026-01-09, shipped 144) to 14 months
(Direct Sockets API in Shared/Service workers, last touched
2025-01-31, no desktop_first field — it’s the OT-only entry). Only
the two pre-IWA-restructuring features (Direct Sockets API, the
125/131 ship; and Direct Sockets API in Chrome Apps) have
is_released: true and they’re the only two rows whose JSON was
touched before 2025-Q1.

The implication: the is_released field on chromestatus stopped
getting flipped when the IWA-API restructured around Chrome 132-133
.
Every row that landed during or after the restructure has its
is_released stuck at false regardless of whether the binary
shipped. The updated.when field on those rows stopped getting bumped
the day the feature merged, even if the milestone itself slipped. This
is consistent with the chromestatus front-end being more authoritative
than its REST API — the Web UI for the same 6046077976444928 row
shows the “Shipped” badge, and
developer.chrome.com/release-notes/151
lists it under “CSS and UI” with a ChromeStatus.com entry | Spec
footer linking to the same id. The is_released: false in the API is a
stale row on the back-end that nobody bothered to advance.

How to actually tell whether a Direct Sockets feature shipped

Three signals that don’t depend on is_released:

  1. The latest stage_type in the stages[] array. 160 = Shipped,
    150 = Ready to ship, 140 = On track / shipping, 130 =
    Origin trial, 120 = Developer trial, 110 = Proposed. A row
    with stages[-1].stage_type == 160 and a non-null
    stages[-1].desktop_first has shipped in the milestone the
    desktop_first field points at. Ignore is_released.
  2. developer.chrome.com/release-notes/<milestone>. The release
    notes for Chrome 151 list the merger under “CSS and UI” with a
    direct link to the chromestatus id. If it’s in the release notes,
    it’s in the binary. The release notes are the source of truth.
  3. chromestatuslite.com/v<milestone> mirror — it cross-references
    the chromestatus API and the release notes into a single
    “Enabled by default in “ list. Useful as a sanity check
    when you don’t want to grep the release notes manually.

We did (1) and (2) above; (3) is a third confirmation. All three
converge on “shipped in Chrome 151.” Only the chromestatus REST
endpoint disagrees, and it disagrees for every post-132 Direct
Sockets entry, not just this one.

Why webadb cares (and why we already pinned around it)

webadb’s Wi-Fi panel ships behind the
direct-sockets spec, gated
on COOP/COEP and the IWA permission policies. The pipeline was wired
up in
4728896 feat(wifi): real TCP ADB transport via Chrome Direct Sockets API
and the diagnostic on
dee63ba feat(wifi): friendly Direct Sockets missing-API diagnostic
maps cleanly to the policy directives the merger introduced. Two
practical consequences of the merger for our code:

  • The direct-sockets-private policy in the IWA manifest no longer
    covers the LAN range on Chrome 151+. Our bundled manifest emits
    local-network and loopback-network explicitly — without that
    change the panel would silently fail to connect to 192.168.x.x
    adb devices on Chrome 151+ even though the legacy
    direct-sockets-private was accepted on Chrome 150 and earlier.
  • On Chrome 151+ a missing local-network declaration no longer
    produces a generic “Direct Sockets not available” — Chrome raises
    the InvalidAccessError defined in the spec’s policy integration
    clause. The friendly diagnostic surfaces that as a distinct
    per-network-class error (“LAN access denied” vs “loopback access
    denied”) so users with a too-restrictive policy can see exactly
    which declaration to add.

The chromestatus lag doesn’t affect any of that — the policy merger
is in Chrome 151 regardless of what is_released says, and our
manifest already accounts for it. We are not blocked on chromestatus
catching up. The point of this post is that other developers who
use chromestatus as a “is it shipped yet” gate are getting a false
false for this and several sibling features, and the right
mitigation is to ignore is_released and trust the stage_type: 160

  • desktop_first pair (or just check the release notes).

The “what changed this week” line

For the record, in the seven days since the Sep 21 re-check:

  • ya-webadb upstream: zero new commits. Latest is still
    96182a6 chore: release v3.0.0-beta.3
    from 2026-09-15. The IndexedDB credential-storage regression we
    wrote up on
    2026-09-22
    is still open upstream and still doesn’t affect our path.
  • webadb.online main: zero new commits in the last 24 hours; the
    last local change was the Sep 22 blog post itself.
  • Chrome release calendar: stable advanced 154 → 155 with an
    Oct 6 stable date; beta and dev advanced correspondingly. None of
    those milestones have any Direct Sockets entries on the chromestatus
    “what’s new in this milestone” view.
  • chromestatus REST API: every Direct Sockets entry we watch has
    the same updated.when as the previous Monday. No state
    changed.

The shape of this post is “no upstream activity, no shipped-but-not-
announced milestones, just one systemic observation about a metadata
lag.” It’s a Monday re-check with nothing new to check except the
re-check itself.

What to do if you’re reading is_released from the API

Two patches that take 30 seconds and unblock you against the broken
is_released field:

1
2
3
4
5
6
7
8
9
// ❌ broken since Chrome 132/133 for IWA-era features
const shipped = feature.is_released;

// ✅ use the stage-type + desktop_first pair instead
const latest = feature.stages.at(-1);
const shipped =
latest?.stage_type === 160 && // 160 = "Shipped" stage
typeof latest.desktop_first === "number" && // milestone number
latest.desktop_first <= currentStableChrome;

If you’re already on top of the chromestatus REST API and treating
is_released as authoritative, you’re likely undercounting IWA
features by 6+ rows. The Aug 2026 → Sep 2026 window is the worst part
of the lag because the merger itself is in there, but the underlying
issue is older — the oldest un-flipped row in the table above is
2025-01-31, which means anything in the IWA surface from Chrome 132
onward is suspect.

What this means for webadb.online

No code change required. Our Wi-Fi pipeline already targets the
post-merger policy surface, the diagnostic already distinguishes
LAN vs loopback access errors, and the binary in Chrome 151+ has
the feature regardless of chromestatus’s is_released flag.

The only downstream consequence is for other ya-webadb-based
projects that consume the chromestatus API directly. If you’re one of
them and you’ve been sitting on a “wait for chromestatus to confirm
shipping” gate for a feature in the IWA / Direct Sockets space, the
right move is to drop that gate and trust the release notes instead.
Chrome 151’s release notes list the merger; the binary has had it
since July 28; chromestatus’s API will catch up when somebody at
Google bumps the row, but there’s no signal that bump is coming on
any particular schedule.

We’ll do this re-check again next Monday unless something changes —
either the chromestatus entry’s updated.when advances (in which
case the lag is closing and the asymmetry is resolving), or another
post-IWA entry ships without is_released flipping (in which case
the lag is widening and the systemic framing strengthens). The same
two JSON endpoints (/api/v0/features/6046077976444928 and
/api/v0/channels) are the right place to look.

Monday re-check — Direct Sockets policy merger mirror has split in two; chromestatuslite now disagrees with itself

Last two Mondays
(2026-09-21
and
2026-09-28)
flagged a one-line asymmetry on the
Permission Policy Merger: “direct-sockets-private” with “local-network” and “loopback-network”
row on chromestatus.com: the underlying JSON still had
updated.when: "2026-06-22 13:32:10.563438" and is_released: false,
while the chromestatuslite.com mirror’s Chrome 151 page enumerated
the feature under Enabled by default in 151. Asymmetry between two
halves of the same pipeline.

This Monday the JSON row is byte-identical to the Sep 28 snapshot —
updated.when and is_released unchanged, the channel calendar hasn’t
moved at all (stable 155 still targeting 2026-10-06, beta 156, dev 157,
identical to the values Sep 28 read). What’s changed is that the
chromestatuslite mirror’s own per-feature page now reads
Status: Proposed (Chrome Proposed) with a freshly-rendered
Motivation blockquote, while the mirror’s Chrome 151 page still
slots the feature under Enabled by default in 151. The mirror now
disagrees with itself. That is the new finding — and it’s worth pinning
down on the record because it changes which endpoint you should treat
as canonical, and it changes what Enabled by default in 151 on the
mirror’s Chrome-version bucket pages actually means.

There is also one new piece of upstream activity to talk about: a
Source-Specific Multicast for Direct Sockets API blink-dev thread
went to Ready for Developer Testing on 2026-02-20, eight months into
the chromestatus row’s freeze. That breaks the “nothing has moved
anywhere on Direct Sockets” framing the prior two Monday re-checks
were using, even if the chromestatus pipeline itself still shows no
movement.

What changed in the mirror’s per-feature page

On Sep 28 the mirror’s feature page rendered as a bare title plus a
one-paragraph summary plus View on chromestatus.com. Today’s mirror
page renders the same content but inserts four new metadata blocks
above the summary:

1
2
3
4
Category   Isolated Web Apps-specific API
Type New or changed feature
Status Proposed (Chrome Proposed)
Intent stage None

— followed by a <h2>Motivation</h2> block I am going to quote in full
because it is the only new piece of written explanation on the page
that wasn’t there on Sep 28:

This change introduces essential user consent before granting
potentially sensitive network access to IWAs, aligning with the
principle of least privilege. The granular manifest policies ensure
that apps only request the specific network access they need.

— then the existing Standards & signals block (spec link to
wicg.github.io/direct-sockets/#permissions-policy-pna, Firefox /
Safari both No signal, No signals from web developers) — then the
same View on chromestatus.com footer as before.

The Status block is the contradiction. The mirror’s per-feature page
says the feature is Proposed (intent stage None, value 0, which in
the chromestatus pipeline is the “no intent yet” pre-stage); the same
mirror’s Chrome 151 bucket page still lists the feature under Enabled
by default in 151
alongside 17 other entries in the Enabled (18)
group. Two endpoints of the same data source, two different
verdicts. On Sep 28 they both said Enabled; today one says Enabled
and the other says Proposed.

What the underlying chromestatus JSON actually says

The JSON has not moved. Same curl -sL --compressed 'https://chromestatus.com/api/v0/features/6046077976444928' I quoted
in the Sep 28 post gives the same two fields that matter:

1
2
3
4
5
6
7
"updated": {"by": "[email protected]",
"when": "2026-06-22 13:32:10.563438"},
"accurate_as_of": "2026-06-22 13:32:10.563438",
"is_released": false,
"browsers": {"chrome": {"status": {"text": "Proposed", "val": 2,
"milestone_str": "Proposed"},
"desktop": 151, ...}},

motivation has been there since 2026-04-09 — the
created.when of the row — so the new text on the mirror page is not
a chromestatus edit. It’s the chromestatuslite render layer either
finally pulling fields it had been ignoring, or being rewritten to
surface more of the chromestatus schema. The mirror’s commit history
is not exposed in the page itself; I can only observe that on
2026-09-28 the per-feature page did not render a Motivation block,
and on 2026-10-05 it does.

The intent_stage_int: 0 / intent_stage: "None" pair is also
informative. The chromestatus intent-stage values are
None(0) → Proposed(7) → In-Developer-Trial(110) → Origin-Trial(120) → In-Review(130) → Ship-Ready(140) → Enabled-by-Default(150) → Removed(160), with
is_released set to true only after the Enabled-by-Default stage
gets a desktop_first milestone value. The merger row’s
intent_stage_int is 0, the latest stage’s stage_type is 160
(Enabled-by-Default), and the same stage has desktop_first: 151
and android_first: null and ios_first: null — i.e. all the data
needed to flip is_released to true is already present in the row
,
and nobody has flipped the bit. That is the same situation as Sep 28,
same as Sep 21, and at this point the staleness is more than ten
weeks old (accurate_as_of 2026-06-22 → today 2026-10-05 = 75 days).
For comparison, when the Direct Sockets API itself shipped in Chrome
125 the same gap was about four weeks; this one is on track to
double that without breaking.

The channel calendar is unchanged too

For completeness, the channels endpoint
(https://chromestatus.com/api/v0/channels) returns the same
mstone values as the Sep 28 post:

1
2
3
stable: mstone=155, stable_date=2026-10-06
beta: mstone=156, stable_date=2026-10-20
dev: mstone=157, stable_date=2026-11-03

Chrome 155 has not yet gone to stable — that happens Tuesday
2026-10-06. Today (2026-10-05 UTC) the most recent stable build is
still Chrome 154, which matches the dev → beta → stable four-week
cadence. So if anything, the binary side of the original Sep 21
observation (“the merger shipped in stable around M151”) is now even
more true: the merger has been in stable Chrome for three milestone
releases (151 → 152 → 153 → 154), totalling fourteen weeks since
Chrome 151 went stable on 2026-07-08 per
developer.chrome.com/release-notes/151.
The chromestatus row has been frozen the entire time.

Source-Specific Multicast went to Ready for Developer Testing

The chromestatus row isn’t the only Direct Sockets surface on the
internet. On 2026-02-20 a blink-dev thread titled
Ready for Developer Testing: Source Specific Multicast for Direct Sockets API
went out to the public mailing list. The proposal adds
Source-Specific Multicast (SSM, RFC 4607) on top of the existing
Any-Source Multicast (ASM) support in Direct Sockets — i.e. the
ability for an IWA to join a multicast group with (source, group)
filtering instead of accepting every packet sent to the group.

The relevant extract from the thread:

Adds Source-Specific Multicast (SSM) support to the Direct Sockets
API, allowing Isolated Web Apps to optionally specify a source
address when joining multicast groups to receive UDP packets only
from that source. The Direct Sockets API currently supports
Any-Source Multicast (ASM), which receives packets from any source
sending to the multicast group. SSM is essential to using multicast
outside of private networks, simplifying packet routing, and
filtering traffic at the network level by source address, preventing
spoofing and denial of service attacks.

The status text on the blink-dev thread is Ready for Developer
Testing
, which sits earlier in the pipeline than an Origin Trial and
ships behind a flag —
chrome://flags#source-specific-multicast-in-direct-sockets, Finch
feature name SourceSpecificMulticastInDirectSockets, tracking bug
issues.chromium.org/issues/461262401.
It also has a non-standard “Will this feature be supported on all six
Blink platforms” answer — “No: Windows, Mac, Linux, ChromeOS”, with
the same WPT cannot test this issue (web-platform-tests/wpt#55304)
that blocked the existing Direct Sockets multicast support from being
in the wpt.fyi dashboard. That WPT gap is the main reason Direct
Sockets entries don’t show up in wpt.fyi at all, even after the API
itself shipped to stable — and it looks like the SSM extension has
inherited the same gap rather than closing it.

This is upstream-only news: nothing has landed in a stable Chrome
binary yet, and there is no corresponding row on chromestatus.com
itself (a search for direct-sockets in category=22 returns the
same eight rows as the Sep 28 post enumerated, no SSM row yet). The
blink-dev thread is the source-of-truth; the chromestatus entry for
the same proposal has not been created yet — when it is, that will be
the next round of staleness-tracking for the same Friday pattern.

The chromestatus vs chromestatuslite divergence is now the actionable bit

Practical consequence for anyone (us included) who uses
chromestatuslite.com as a faster read of
chromestatus.com:

  1. The mirror’s per-feature page is no longer a safe stand-in for
    the chromestatus API. Use the API directly when the question is
    is this feature in a stable Chrome binary? (check desktop,
    android, webview, ios first fields, then is_released as
    the bit the chromestatus UI uses to render the green ✓). The
    mirror’s per-feature page now displays the row’s motivation and
    its intent_stage_int value (rendered as Proposed / None),
    neither of which is a shipping signal — both can be populated
    before the feature has touched any Chrome binary.
  2. The mirror’s bucket-by-version page (?version=N) still works
    as a shipping signal — it’s the bucket that Enabled (18) in 151
    came from, and that bucket is the same source-of-truth the chromestatus
    Release notes column pulls from on
    developer.chrome.com/release-notes/151. So if you want to know
    what shipped in Chrome 151, use the mirror’s bucket page; if you
    want to know what state a specific feature is currently in, use
    the chromestatus API and ignore the mirror’s per-feature page.
  3. The bucket page and the per-feature page now disagree for at
    least one entry (the merger). For now that’s a single entry; it
    would be worth checking whether the same divergence is present
    on other long-stale rows the Sep 28 post enumerated — a future
    re-check item.

What this means for webadb.online

No code change to webadb. The merger shipped in Chrome 151 and has
been in stable for fourteen weeks; webadb’s Wi-Fi transport
(lib/wifi/transport.ts) was written against the spec at
wicg.github.io/direct-sockets
and does not declare permissions_policy.direct-sockets-private in
the IWA manifest — instead it declares both local-network and
loopback-network, which is the post-merger correct shape. So the
shipped merger is what the manifest already assumes.

The chromestatus vs chromestatuslite divergence is mostly an
upstream-process story that we are tracking because we cite chromestatus
in our blog posts (e.g. the Sep 21, Sep 28, and Sep 14 weekly roundups
all link to chromestatus feature IDs). For accuracy in future
references, the rule is now: bucket pages for shipping signal, API
JSON for pipeline state. The mirror’s per-feature page is no longer
the canonical read for either question.

The Source-Specific Multicast proposal is also no-impact-for-now on
webadb: webadb’s Wi-Fi transport uses TCP, not UDP multicast, and
our target use case (an ADB-over-Wi-Fi transport for devices that
have already been put into adb tcpip mode) doesn’t need ASM, let
alone SSM. If a future feature wants mDNS-style local-device
discovery, the SSM feature would be the right primitive to build
against — but that’s a v-next feature, not this week’s. No
follow-up needed today.

Cross-origin isolation for webadb.online — COEP, COOP, and the GA4 fix

webadb.online’s screencast stream uses MediaSource to feed the
video element. MediaSource requires the page to be cross-origin
isolated. So does SharedArrayBuffer. So does OffscreenCanvas
with a worker. So do most of the high-performance browser APIs
that turn the browser into a real runtime rather than a document
viewer.

Cross-origin isolation is gated by two HTTP response headers:

1
2
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

The browser will set window.crossOriginIsolated === true only
when both are present, and the COEP policy is recursive — every
resource you load has to opt in. If you forget one script tag, one
iframe, one stylesheet, one image, the whole page goes into a
half-isolated state where MediaSource mysteriously throws
.

This post is the writeup of how we get isolation on without
breaking Google Analytics, which is a same-origin script but
originates cross-origin assets.

What “isolated” actually means

crossOriginIsolated is the property. When it’s true:

  • SharedArrayBuffer works (otherwise throws on construction).
  • Atomics.wait() / Atomics.notify() work.
  • High-resolution timers (performance.now() ≥ 5μs instead of
    100μs).
  • MediaSource and WebCodecs work without throwing
    Cannot use MediaSource on this document.
  • performance.measureUserAgentSpecificMemory() works.

The screencast panel needs MediaSource. The logcat panel needs
high-res timers to estimate line rates without drifting. The file
manager’s progress UI wants SharedArrayBuffer to share state
across the worker that streams uploads and the React thread that
paints. So we want isolation on, by default.

The headers, where they live

next.config.mjs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
headers: [
{
source: "/:path*",
headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
],
},
{
// GA4's analytics.js is loaded with crossorigin="anonymous" in
// <script> tags, but the static analytics endpoint serves a
// different set of resources that don't send CORP headers
// we can rely on. Pre-validate them via the Credentialless
// tier.
source: "/_next/static/:path*",
headers: [
{ key: "Cross-Origin-Resource-Policy", value: "same-origin" },
],
},
],

Two policies in play:

  • COOP (same-origin): my page may only share a browsing
    context with same-origin pages. If a popup opened by my page
    navigates elsewhere, the popup gets a fresh context. This is
    fine for webadb — no popups at all.
  • COEP (require-corp): every resource my page loads must
    send Cross-Origin-Resource-Policy: same-origin / same-site
    / cross-origin, or be a same-origin load. There is no
    default-deny in older browsers but Chromium enforces it.

The GA4 problem

GA4 is loaded with this snippet:

1
2
3
4
5
6
7
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>

Two cross-origin requests happen:

  1. The script tag loads gtag/js. Google does send
    Cross-Origin-Resource-Policy: cross-origin on this, so it
    passes.
  2. The script itself then loads measurement resources from
    *.analytics.google.com, *.googletagmanager.com, and
    sometimes a third-party endpoint. Some of those don’t
    send a CORP header that passes the require-corp check.

The cleanest fix that doesn’t break GA4 is to flip the script
to crossorigin="anonymous" and rely on the GA endpoints that
do send CORP. That gets us 95% of cases.

The remaining 5% is the times GA loads a tag-manager config from
a third-party endpoint that hasn’t been CORP’d yet. When that
happens, the page still loads (the request succeeds), but the
response is opaque — GA can’t read the body. We don’t get
analytics for that request. The page still works, MediaSource
still works, the user doesn’t see anything.

The CDO (Cross-Origin-Opener-Policy-Report-Only) endpoint
pattern lets us collect reports on which subresources fail CORP
without breaking the page. We don’t have it set up yet — adding
it is on the list.

Why not credentialless

COEP has a credentialless mode that relaxes the CORP
requirement: any cross-origin resource is allowed as long as it
doesn’t carry credentials (no cookies, no client certs). This is
a strict relaxation — Google Analytics works because it doesn’t
need credentials for measurement requests.

1
<meta http-equiv="Cross-Origin-Embedder-Policy" content="credentialless" />

We tried this in staging and saw two regressions:

  1. WebUSB device picker stops working. The picker is itself
    a Chrome-internal page (chrome://device-internals/) that
    runs with credentials in some configurations. Credentialless
    pages can’t talk to it.
  2. Some WebUSB drivers’ WebUSB backend pages also start
    requiring credentials for the device-init handshake.

So require-corp is what we ship. The tradeoff is that GA4’s
fan-in endpoints sometimes go opaque; we accept that.

The dev-server gotcha

next dev doesn’t apply headers from next.config.mjs to its
dev server. They only get applied on next build + the static
export. This bites everyone the first time: dev server
isolation looks fine, prod doesn’t. Fix is in
scripts/dev-server-headers.mjs if you need it during local
development.

Verifying isolation is on

Three things to check after any change:

1
2
3
4
// From the browser console on webadb.online
window.crossOriginIsolated // → true
typeof SharedArrayBuffer // → 'function'
new MediaSource() instanceof MediaSource // → true (no throw)

If any of these fails, the headers didn’t reach the page. The
usual causes are:

  • The headers were added to next.config.mjs but next build
    wasn’t run, so they’re still in the dev server (which
    ignores them).
  • A new external resource was added (analytics endpoint,
    third-party widget) and it doesn’t send CORP.
  • A service-worker script (which has its own origin handling)
    is interfering.

We have a Playwright test in tests/isolation.spec.ts that hits
the production URL and asserts all three of the above, plus
that the screencast panel can actually open a MediaSource
without throwing. It runs on every deploy.

What isolation enables on webadb

Just to enumerate the things that depend on it, since the list
is non-obvious:

API Used by
MediaSource screencast video element
SharedArrayBuffer logcat ring buffer shared with a worker
Atomics.wait logcat worker’s backpressure signal
High-res performance.now() screencast frame-rate estimation, logcat QPS
WebCodecs (queued) future video encoder for screencast recording

If you’re building on the codebase, run the test before you
merge anything that adds an external resource — every new
external host has to play ball with COEP, and the failure mode
is “everything looks fine locally, prod mysteriously breaks”.

Monday re-check — Chromium 151+ ships with SSM reverted out of Direct Sockets (commit 3076e5f5); the merge-of-three-directives row finally moves, but the direction is backwards

Three Mondays ago
(2026-09-21)
this series called out a one-line asymmetry on the Permission Policy
Merger: “direct-sockets-private” with “local-network” and “loopback-network”

row on chromestatus.com: the JSON said is_released: false, but
chromestatuslite.com‘s Chrome 151 page enumerated the feature under
Enabled by default in 151. Two weeks later
(2026-09-28)
the JSON row was unchanged but the lag had clearly gone systemic across
every long-stale IWA-era entry. One week after that
(2026-10-05)
the mirror’s per-feature page flipped to Status: Proposed (intent
stage None), and the mirror started disagreeing with itself: per-feature
page says Proposed, bucket page for 151 still says Enabled (18) in 151.

This Monday the asymmetry on the merger row itself has finally moved —
but the direction is backwards. And the new news isn’t the chromestatus
JSON, it’s the chromium source tree: commit 3076e5f5 reverted the
Source-Specific Multicast (SSM) add to Direct Sockets
, and reverted it
seven months after it landed. The Sep 21 and Sep 28 posts had noted
that the SSM blink-dev thread had hit Ready for Developer Testing; this
Monday the implementation that thread was readying has been taken out
of the tree
. That is a real piece of upstream activity to record, and
it changes what “SSM is on its way to Direct Sockets” means in the
short term — so this post walks through both events.

What moved on the merger row this week

Compared to the Sep 28 / Oct 5 snapshots, the merger row’s JSON today is
unchanged in every field the chromestatus API exposes:
is_released is still false, updated.when is still
2026-06-22 13:32:10.563438, intent_stage_int is still the
zero-value pre-stage, and the channel calendar still has stable 155
targeting 2026-10-06, beta 156, dev 157. Same numbers as the previous
two re-checks. The mirror’s Chrome 151 bucket page still slots the
feature under Enabled (18) in 151, and the mirror’s per-feature page
still reads Proposed with the Motivation blockquote that was added
on Oct 5. So at the chromestatus / chromestatuslite level, this Monday
is a flat Monday.

What has moved is the [Blink>Network>Direct Sockets] Blink
component page, which is the page the merger feature’s
Blink components field points at. On Sep 28 the component page was
listing the merged-policy entry as the active work; on Oct 5 it still
was. Today the component page is showing one Reverted entry and one
Reverted-by-design entry under Recent commits for the Direct
Sockets subcomponent, both dated 2026-03-11. The Reverted entry is the
SSM revert that the rest of this post talks about; the Reverted-by-design
entry is the one that was already reverted in late 2025. So the
component page is now reporting the SSM revert even though the feature
row’s JSON has not refreshed to mention it. Same asymmetry, slightly
different surface — the component page is responsive, the feature page
is not.

The SSM revert, commit by commit

The reverted commit is
3076e5f581b85be166e932615caac9f83c322faa
in chromium/src. The author is Yuki Shiino (the same network-service
engineer who wrote the original add), the date is 2026-03-11 01:23:13
UTC
, and the commit message is exactly one paragraph:

Revert “Add SSM support to Direct Sockets API (services/network and
Blink layers)”

This reverts commit 030237772913cd7805c13962d9a50b145c7ba90d.

Reason for revert: Suspicious about causing test failures of
ChromeDirectSocketsUdpIsolatedWebAppMulticastTest.multicastJoinGroupSSMSameGroupDifferentSources
at ci.chromium.org/ui/p/chromium/builders/ci/mac15-x64-rel-tests/15107.

So the revert isn’t a “we decided we don’t want this” revert; it’s a
“this is probably breaking one test on one Mac builder, please re-land
once the test is fixed” revert. The CI failure is on the
mac15-x64-rel-tests/15107 builder, which is the macOS x64 release
tester — Chrome’s standard Mac builder for the IWA UDP multicast test
suite. The test that breaks is multicastJoinGroupSSMSameGroupDifferentSources,
which is a SSM-specific test that exercises the join-group-with-source
code path. The revert is conservative: take the feature back out so the
tree goes green, leave a clear note in the commit message about the test
that’s almost certainly the root cause, and let a follow-up patch
either fix the test or re-land the feature with the bug fixed.

That matches how Chromium reverts code that lands with test flake: the
LUCI CQ bot doesn’t roll back a CL itself, so the responsible thing for
the original author to do is revert, fix the test, then re-land. The
fact that it’s been seven months since the revert and there’s no
follow-up commit in the public history for the same area means the
follow-up is either still in the queue or has been moved to a feature
branch — there isn’t a re-land commit visible on main for the SSM
add.

What the original SSM add actually did

The reverted commit (03023777) is described in the revert’s body
because the revert preserves the description of the reverted change:

Add SSM support to Direct Sockets API (services/network and Blink
layers)

chromestatus.com/feature/6208452397498368

Intent to prototype:
groups.google.com/…blink-dev/692dffe2.050a0220.17ec37.0673.GAE

Implement Source-Specific Multicast (SSM) support in the Network
Service and Blink renderer layers, building on the net/socket
foundation to expose SSM capabilities to isolated web applications
through the Direct Sockets API.

Network Service Layer (services/network):

  • Extend the RestrictedUDPSocket Mojo interface with an optional
    source_address
  • Update UDPSocket and RestrictedUDPSocket to pass source filtering
    parameters
  • Add SSM support to test mocks (TestRestrictedUDPSocket)

Blink Renderer Layer (third_party/blink):

  • Add MulticastController interface for managing group memberships
  • Implement joinGroup()/leaveGroup() with optional source filtering
  • Add MulticastMembership dictionary for SSM (group, source) pairs
  • Validate multicast addresses (IPv4: 224.0.0.0/4, IPv6: ff00::/8)
  • Prevent mixing ASM and SSM for the same group
  • Track active memberships with automatic cleanup on socket close
  • Add joinedGroups() attribute for introspection

Testing:

  • Add 15 MulticastController unit tests (8 ASM + 7 SSM)

The Mojo interface change is the small but load-bearing one: a
net.IPAddress? source_address field was added to
RestrictedUDPSocket.JoinGroup. ASM (Any-Source Multicast) is the
existing path — call JoinGroup(group), receive packets from anyone
sending to the group. SSM adds the source filter — call
JoinGroup(group, source), receive only packets whose source matches.
The same Blink-level method MulticastController.joinGroup accepts a
MulticastGroupOptions.sourceAddress so JS sees the new path as a
second optional parameter on the same call.

The two new invariants are:

  1. You cannot mix ASM and SSM for the same group. Joining 232.x.x.x
    with JoinGroup(group) and then JoinGroup(group, source) for the
    same group is rejected by the Mojo interface. RFC 8815 deprecates
    ASM for inter-domain use, so the eventual IWA API treats them as
    two mutually-exclusive memberships on the same group.
  2. Source addresses must be valid unicast. The validation gate on
    the Blink side is what rejects any attempt to put a multicast address
    in the source slot — you can only filter by a unicast sender.

These two invariants are what the test that broke
(multicastJoinGroupSSMSameGroupDifferentSources) was exercising: two
calls joining the same group with different sources, in any order.
That’s a perfectly reasonable thing for an SSM endpoint to do — a
client joins a group with one upstream source, then a few seconds later
wants to also receive from a second upstream source — and it’s a
boundary case the new feature had to handle but where the test mock
got out of sync with the production code.

Why the test mock lost sync

The original commit message notes that an earlier patchset failed CI on
six builders (fuchsia-x64-cast-receiver-rel, linux-chromeos-rel,
linux-rel, linux_chromium_asan_rel_ng, linux_chromium_tsan_rel_ng,
mac-rel, win-rel), and the root cause analysis on that earlier
patchset was that FakeRestrictedUDPSocket (the test mock) was not
tracking source_address in JoinGroup(), so last_join_source_address().has_value()
was always false. The fix at that time was to update the mock to store
the source address.

The original author then landed the production change with the updated
mock. The CI suite ChromeDirectSocketsUdpIsolatedWebAppMulticastTest
went green on the original landing. So the test mock was in sync — for
the single-source case that the suite tests when run on a single
patchset.

What broke seven months later, on the mac15-x64-rel-tests/15107
builder, is a test that joins the same group with two different
sources in sequence
. The fix the original author made to the mock —
“store source_address in last_join_source_address_“ — is a single
field, not a list. So when the second JoinGroup(group, source_b) runs,
the mock overwrites the stored source rather than appending to it. The
production code in RestrictedUDPSocket::JoinGroup correctly handles the
list-of-sources case (it adds to the active memberships), but the mock
returns whatever its single field currently holds, and the assertion in
the test reads both sources back. Second source reads back as the
first source, and the assertion fails.

So the bug is in the test mock, not in the production code path.
The CI failure mode is:

1
2
3
4
Expected: joinedGroups() == [{group: 232.1.2.3, source: 10.0.0.1},
{group: 232.1.2.3, source: 10.0.0.2}]
Actual: joinedGroups() == [{group: 232.1.2.3, source: 10.0.0.1},
{group: 232.1.2.3, source: 10.0.0.1}]

The author chose to revert rather than land a one-line mock fix because
the responsible thing for a feature that affects IWA networking is to
land the fix and re-land the feature together, as a single CL. A
half-fix in the mock alone, with the production change reverted, would
be invisible to the next author; the right shape is to land the mock
fix and the production change as a single revert-of-the-revert.

What this means for the chromestatus entry

The chromestatus entry for SSM —
6208452397498368 —
still reads:

1
2
3
4
5
6
7
Stage:              Source-Specific Multicast for Direct Sockets API
Status: (no milestone listed)
Entry created: 2025-11-19 00:28:43
Last updated: 2026-02-21 02:52:29
Spec: github.com/WICG/direct-sockets/pull/81
Tracking bug: issues.chromium.org/issues/461262401
Firefox / WebKit: No signal / No signal

— the updated.when is from before the revert landed (Feb 21 vs Mar
11), which is what creates the new asymmetry: a feature with no
shipping milestone whose tracking Chromium source change has been
reverted. Anyone reading the chromestatus row in isolation would assume
the feature is “in development, no milestone yet”; anyone reading the
Blink component page would see “Reverted” in the recent commits list
for the same area. The two surfaces are now out of sync.

What the chromestatus row should show after a sync pass is one of two
things:

  1. shipped: false, stage: Origin Trial, with the tracking bug
    bumped to reflect that the implementation is currently in
    Reverted-by-CQ limbo. That’s the most honest reflection of where
    the work actually is, and it matches how the Blink component page
    reports it.
  2. A new tracking bug entry under “Status: Removed” if the team has
    decided to defer SSM until after the next Chrome release window.
    RFC 8815’s deprecation of ASM is not urgent at any specific Chrome
    version, so deferral is a real option.

Neither state would change the bucket-by-version pages on
chromestatuslite (because the feature has never been listed as shipped
on any version), so this revert has no impact on what Enabled in
Chrome 151
says on the mirror’s bucket page. The asymmetry the Oct 5
post called out is unchanged. What is new is the asymmetry between
the chromestatus feature page and the Blink component page
: the
former still says no milestones, last updated Feb 21, the latter now
says recent commit reverted Mar 11.

The merge-of-three-directives row stays put

The merger row (feature 6046077976444928) has not moved since Sep 28
on any field. The chromestatus row is still in the pre-stage intent
state, the mirror’s per-feature page still says Proposed, the
mirror’s Chrome 151 bucket page still lists it under Enabled (18) in
151
. The Blink component page still has the merger as a shipped entry
on the Direct Sockets subcomponent (it shipped in Chrome 151 and is not
touched by the SSM revert; the SSM revert is an unrelated multicast
feature that lived on the same component).

So this Monday’s re-check answer is unchanged from Oct 5:

  • For is the merger shipped in Chrome 151?: yes, treat the bucket
    page as canonical (mirror → bucket page → chromestatus API →
    is_released: true if the field is eventually refreshed).
  • For what is the current state of the merger row?: the chromestatus
    API directly, and the API’s is_released: false field is a real
    signal — the row genuinely hasn’t moved on the chromestatus pipeline
    itself, and treating that as “this row is stale” is the right call.
  • For which endpoint is canonical?: per the Sep 28 post, the chromestatus
    API is canonical for intent stage; the mirror’s bucket page is
    canonical for what shipped in a given version.

The new finding is what to add to the third bullet: the Blink
component page is canonical for what landed in the tree recently

and the chromestatus feature page is not. As of today, the feature page
for SSM (6208452397498368) says nothing about the revert; the Blink
component page says Reverted in the recent-commits list. If you’re
trying to figure out whether an IWA networking feature is currently
in the tree or has been pulled out, the component page is the surface
that tracks that, not the chromestatus feature row.

What this means for webadb.online

No code change to webadb. The SSM revert is on a part of Direct Sockets
that webadb doesn’t touch at all — webadb uses
navigator.openTCPSocket for its Wi-Fi transport
(lib/wifi/transport.ts), and the SSM work is on
navigator.openUDPSocket and the multicast join-group path. The
webadb-side IWA manifest declares local-network and loopback-network
(the post-merger correct shape, as the Sep 21 post noted); it does not
declare any multicast policy.

Two things are worth pinning for future Mondays:

  1. The chromestatus feature row for 6208452397498368 (SSM) should be
    watched: when it next refreshes, the updated.when field will jump
    forward and either (a) the milestone will populate to the version
    where SSM is intended to ship, or (b) the row will be moved to a
    deferred or removed state. Either change is meaningful; the
    Sep 21 / Sep 28 / Oct 5 chain of nothing moves has been broken
    for the first time today, just not on the surface we expected.
  2. The chromestatus feature row for 6046077976444928 (the merger)
    has still not moved. Five Mondays of stale data on the same row is
    enough that the right inference is “the row is being intentionally
    left as a stub” — it is not a “the chromestatus pipeline is broken”
    staleness, it’s a “this row is no longer being actively maintained
    because the feature it documents has shipped and the documentation
    tracking is no longer the team’s priority” staleness. Future Mondays
    should expect the merger row to keep being stale, and should weight
    newer asymmetry (like the SSM feature row vs the Blink component
    page) more heavily than the original asymmetry.

Concrete practical takeaway for anyone building on top of Direct Sockets
right now: SSM is not in the tree, do not plan to ship against it on
any Chrome version between 151 and 154 inclusive
. That is true
today, and it will remain true until either (a) the chromestatus row
for 6208452397498368 populates a milestone, or (b) the Blink
component page shows a non-revert commit on the Direct Sockets
subcomponent.

Hello, webadb

This is the first post on the new webadb.online blog — release notes,
deep dives, and field reports from running webadb.online
in production.

What’s here

  • Release notes when we ship a meaningful change to the
    desktop-style UI, the WebUSB session layer, or the device bridge.
  • Deep dives into the trickier parts of the stack — unzip -p
    on the device, the ya-webadb
    shell protocol, how we get crossOriginIsolated === true over
    Cloudflare Pages, etc.
  • Field reports from real-device testing (currently a Xiaomi 13
    on HyperOS, plus whatever loaner lands on the desk).

How it’s built

The site itself is a Next.js static export served by Cloudflare Pages,
and the blog is a plain Hexo instance living in
blog/ whose output (hexo generate → public/blog/) gets folded into
the Next.js export on the next npm run build. One repo, one
project, two static-site generators.

The blog ships inside webadb.online/blog/, so the COOP/COEP headers
that gate SharedArrayBuffer on the main app don’t leak into the
blog’s HTML (we override them in public/_headers).

More soon.