ya-webadb v3.0.0-beta.3 — sync send v1 actually waits for the device to ACK before resolving

ya-webadb cut v3.0.0-beta.3 last night (release commit 96182a6b, Sep 17 21:52 UTC+8). The bump to 3.0.0-beta.3 across the 20-package monorepo is mechanical — what’s worth writing down is the single bug-fix that justifies the new tag: 69fffaf0, Simon Chan + Qingyu Wang, fix(adb): wait for sync send v1 completion (#869). The fix closes a race in sendV1 (and the un-compressed branch of sendV2) where the writable returned to the caller could resolve before adbd had actually accepted the bytes — meaning a .close() on the upload stream silently lied about success.

This is the single most webadb-relevant upstream change since the v3.0.0-beta.1 bump that the project is already on. Below: what the bug actually was, the new DelayedCloseWritableStream wrapper that fixes it, and the direct implication for webadb’s file-upload path.

What was wrong

pushBytes in lib/adb-client.ts is webadb’s only sync send path; it calls into @yume-chan/adb‘s sync.send() which delegates to either sendV1 or sendV2 depending on what adbd advertises. The relevant code lives in libraries/adb/src/service/sync/request/push.ts.

The contract a sync SendSession advertises is straightforward: the writable is a WritableStream, you write your file body into it, you call .close(), and when the close promise resolves the device has acked the upload. Internally that means the stream needs to chain (a) the writer closing its end, (b) the internal pipe draining all bytes into socket.writeRequest(RequestId.Data, ...), and (c) the OkResponse from adbd confirming the file landed.

The old sendV1 did this:

1
2
3
4
5
6
7
8
9
const distributeStream = new DistributionStream(packetSize, true);
const sendStream = new SendWritableStream(pool, socket, mtime);
void distributeStream.readable.pipeTo(sendStream).catch(NOOP);

return {
writable: distributeStream.writable,
get bytesWritten() { return sendStream.bytesWritten; },
...
};

Three things are wrong at once:

  1. The returned writable is distributeStream.writable, not the pipe-then-send chain. When the caller calls .close() on it, distributeStream shuts down its writable side, but the pipeTo(sendStream) was already kicked off as a fire-and-forget void ... .catch(NOOP) — meaning a rejection on that pipe (which would surface as “device dropped the socket mid-upload”, “adbd sent FAIL”, etc.) gets swallowed by the .catch(NOOP) and never reaches the caller’s await writable.close().
  2. The SendWritableStream itself resolves its #resolver only when readResponse(ResponseId.Ok, OkResponse) completes — i.e. when adbd acks. That promise is what the pipeTo waits on. Because the caller’s close() only awaits distributeStream.writable.close(), it doesn’t transitively wait for the pipeTo.
  3. sendV2‘s un-compressed branch had the same shape: a MaybeConsumable.WritableStream wrapper around the distribute-writer whose close() only await writer.close(); await pipe; — which sounds right, but the pipe was a top-level local with no .catch, so a rejection on the pipe before close also fired as an unhandled rejection.

The user-visible symptom was specific: small file uploads worked. Large file uploads (anything that exceeded the device-side write buffer and forced sendV1 to actually wait on the response) sometimes resolved with a “success” but the file was truncated on the device, or failed silently while the UI cheerfully reported “Uploaded 12,438,221 bytes” — because bytesWritten reflects what we tried to send, not what the device acknowledged. For webadb’s File Manager “Upload” button, this has been the most likely cause of the rare “I uploaded a file, the panel said success, the file isn’t there” reports.

What changed

The fix is two new pieces plus a refactor of three.

New: DelayedCloseWritableStream (libraries/stream-extra/src/delayed-close-writable.ts, 78 lines, all in this PR). It’s a WritableStream wrapper that takes a target writable and a Promise<unknown> representing the downstream work. Its close() runs Promise.allSettled([writer.close(), promise]) — so it now actually waits for both halves of the upload pipeline to finish, and surfaces a clean AggregateError([closeReason, pipeReason]) when both fail.

The constructor also attaches void promise.catch(() => {}) to suppress the unhandled-rejection warning when callers don’t await the pipe — the long-standing dance for fire-and-forget promises.

New: sendV1.spec.ts (libraries/adb/src/service/sync/request/push.spec.ts, 155 lines). Mocks a SocketPool and a WritableStream controller for the response, then asserts that closing sendV1‘s returned writable only resolves after OkResponse comes back. The reproduction case: write N data chunks, close the stream, and verify the close promise stays pending until the test code enqueues an OkResponse into the response controller. Before the fix, await sendV1.writable.close() resolved the moment distributeStream drained, not the moment the device replied.

Refactored: sendV1 and sendV2‘s un-compressed branch in push.ts. Both now wrap their distributable writer + pipe-to-sendStream chain in new DelayedCloseWritableStream(...). The diff for sendV1 is the smallest:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// before — fire-and-forget pipe, returned writable never awaited the response
const distributeStream = new DistributionStream(packetSize, true);
const sendStream = new SendWritableStream(pool, socket, mtime);
void distributeStream.readable.pipeTo(sendStream).catch(NOOP);

return { writable: distributeStream.writable, ... };

// after — wrapper awaits the pipe AND the writer's close
return {
writable: new DelayedCloseWritableStream(
distributeStream.writable,
distributeStream.readable.pipeTo(sendStream),
),
get bytesWritten() { return sendStream.bytesWritten; },
...
};

sendV2‘s compression branch gets the same treatment, with an additional MaybeConsumable.WrapWritableStream layer around compressStream.writable so the bytesWritten counter is updated before the bytes enter the compressor (matching the previous semantics where bytesWritten was un-compressed size).

SuppressedError is also used now in SendWritableStream.#finish — if a write throws and there’s already an in-flight Ok or error response, the rejected write error is wrapped in SuppressedError(e, priorError) so the caller sees both reasons rather than losing one to a catch-override.

Drive-by: WebUSB transport (libraries/adb-daemon-webusb/src/device.ts). The connection’s writable was rebuilt with pipeFrom(duplex.createWritable(...), new AdbPacketSerializeStream()) — but pipeFrom itself was being removed (see below). The replacement uses the same serializeStream + DelayedCloseWritableStream pattern: the serializer pumps packets into the USB endpoint, and the wrapper ensures close() waits for the serializer’s tail packet (the zero-length one used to mark end-of-transfer on packet-aligned payloads). For WebUSB this is the same race: a writable.close() could resolve before the final zero-packet hit the device, which on some Android kernels means the last chunk gets dropped because the OS hasn’t seen the transfer terminator.

Removed: pipeFrom from libraries/stream-extra/src/pipe-from.ts and index.ts. It was a small helper that did exactly the pipe-then-return-writable dance the sync-send code was using — and it had the same race. Now that the only callers were rewritten to use DelayedCloseWritableStream directly, it’s gone. The index.ts export shrinks by one symbol.

Also: ReadableStream.from polyfill in libraries/stream-extra/src/global/streams.ts switched from if (!ReadableStream.from) { ... } guard to ReadableStream.from ??= .... The previous guard overwrote the native from even when it existed; the ??= assignment lets any native implementation win. Same for ReadableStream.prototype.values and Symbol.asyncIterator. This is in the same PR because the sync-send test file uses ReadableStream.from (via the test mock setup) and was getting the wrong behavior on Node 22+ where ReadableStream.from is native.

What this means for webadb.online

The patch is already on npm under the @3.0.0-beta.3 tag. webadb’s package.json currently pins ^3.0.0-beta.1 for @yume-chan/adb, @yume-chan/adb-credential-web, @yume-chan/adb-daemon-webusb, and @yume-chan/stream-extra. The caret-range means npm install against the latest tag will resolve to 3.0.0-beta.3 automatically — the next npm install run on webadb.online will pull the fix without a package.json edit. The DelayedCloseWritableStream is additive (a new export from @yume-chan/stream-extra); nothing in webadb’s lib/adb-client.ts needs to change because the fix lives entirely inside sync.send()‘s sendV1 / sendV2.

Concretely, the user-visible behaviors this fixes:

  1. File Manager “Upload” finishes accurately. Today, large uploads can report success while the bytes are still in flight. After upgrade, the progress bar reaches 100% and stays there until the device actually acks. The “Upload” button stays disabled until the close promise resolves. No more silent truncation.
  2. APK install via the AppManager panel — that path also flows through sync.send(). Same race; same fix. APK installs that previously appeared to succeed on a flaky USB connection will now fail loudly (with an AdbSyncError from the pipe) instead of appearing to complete while leaving a partial APK on /data/local/tmp.
  3. No more unhandled rejection warnings in DevTools from the void pipeTo(...) paths when an upload fails mid-stream. The DelayedCloseWritableStream constructor attaches .catch(() => {}) internally, so the wrapper itself doesn’t leak unhandled rejections.

The fix is also a clean example of the project’s stance on transport correctness: the PR’s title is “wait for sync send v1 completion”, not “make sync send v1 usually work” — the bar is that await writable.close() must be a contract that callers can rely on, not a best-effort signal. That’s exactly the contract lib/adb-client.ts‘s pushBytes was already written against; the upstream change just makes the implementation honor it.

Action item: when the next webadb dependency refresh runs (no package.json edit required), run npm ls @yume-chan/adb and confirm it resolves to 3.0.0-beta.3. If anyone hits a “Upload failed: pipe aborted” in the File Manager after the bump — that is the fix working. Previously that error was eaten by .catch(NOOP) and the upload silently claimed success.

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.

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.

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.

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.

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.

ya-webadb v3.0.0-beta.4 ships scrcpy 5.0/5.0.1 aliases and rewrites `bipedal` around `Generator`

ya-webadb v3.0.0-beta.4 was tagged on 2026-10-09 with four commits
on top of v3.0.0-beta.3:

  • d8d1ddc —
    feat(scrcpy): add version 5.0 and 5.0.1 as aliases
  • 53f3b7a —
    fix: use consistent Node.js type names
  • 991335b —
    chore: run prettier --check in CI
  • 7bcec61 —
    chore: release v3.0.0-beta.4

It also pulls the two commits that were already sitting on main and
un-tagged when we wrote up
2026-10-04
(PR #871’s IndexedDB fix and the WebUSB NotFoundError handler) into a
coherent beta release. The headline feature is the new
AdbScrcpyOptions5_0 / 5_0_1 client classes that let consumers talk
to scrcpy server 5.0 and 5.0.1. The interesting infrastructure work
is the rewrite of bipedal in
@yume-chan/struct,
which switched from a hand-rolled { resolved, error } sentinel
protocol to a normal iterator.next(value) / iterator.throw(error)
generator protocol.

This post is the read-through: how the new scrcpy aliases plug into
the existing version-permutations architecture, why the maintainer
went for aliases instead of a real 5_0 implementation (and why that’s
fine), what bipedal actually does under the hood, and what changed
in the rewrite that’s worth noticing even if you never call
bipedal yourself.

The new 5_0 / 5_0_1 aliases are version-permutations, not a rewrite

The repo has had one source file per scrcpy server version since
1.x. Files like 2_1.ts, 3_3_3.ts, 4_0.ts, 4_1.ts each declare
a class extending the matching ScrcpyOptions<X> type from
@yume-chan/scrcpy. The pattern for beta.4 is the same — what changes
is that the “latest” pointer now flips from 4_1 to 5_0_1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// libraries/adb-scrcpy/src/latest.ts (beta.3)
import { AdbScrcpyOptions4_1 } from "./4_1.js";

export class AdbScrcpyOptionsLatest<
TInit extends AdbScrcpyOptions4_1.Init = AdbScrcpyOptions4_1.Init,
> extends AdbScrcpyOptions4_1<TInit> {
constructor(init: TInit, clientOptions?: AdbScrcpyClientOptions) {
super(init, clientOptions);
}
}

// libraries/adb-scrcpy/src/latest.ts (beta.4)
import { AdbScrcpyOptions5_0_1 } from "./5_0_1.js";

export class AdbScrcpyOptionsLatest<
TInit extends AdbScrcpyOptions5_0_1.Init = AdbScrcpyOptions5_0_1.Init,
> extends AdbScrcpyOptions5_0_1<TInit> {
constructor(init: TInit, clientOptions?: AdbScrcpyClientOptions) {
super(init, clientOptions);
}
}

Two new files appear, both doing exactly the same thing as their 4_1
counterparts:

1
2
3
4
5
// libraries/scrcpy/src/5_0.ts (new in beta.4)
export { ScrcpyOptions4_1 as ScrcpyOptions5_0 } from "./4_1/index.js";

// libraries/scrcpy/src/5_0_1.ts (new in beta.4)
export { ScrcpyOptions4_1 as ScrcpyOptions5_0_1 } from "./4_1/index.js";

That is, scrcpy server 5.0 re-exports ScrcpyOptions4_1 under a
new name.
No new options, no new control messages, no new codec
quirks. The maintainer has decided that scrcpy server 5.0 and 5.0.1
are wire-compatible with 4.1 for the purposes of the option-shape
the library models (video codec, audio codec, control socket layout,
video metadata orientation). This matches the pattern used for older
jumps where, for example, 3_0_3 re-exported 3_0_1‘s options. The
opt-set of a scrcpy server release tends to change very little
between patch versions, so re-exporting is the right amount of code.

What does differ is the version string the client sends on the wire:

1
2
3
4
5
6
// libraries/adb-scrcpy/src/5_0_1.ts (new in beta.4)
constructor(init: TInit, clientOptions?: AdbScrcpyClientOptions) {
super(init);
this.version = clientOptions?.version ?? "5.0.1";
this.spawner = clientOptions?.spawner;
}

The server uses that string to decide whether it should accept the
connection (newer servers reject older version strings; older servers
reject newer ones in some cases). By default the client says
"5.0.1", but clientOptions.version lets you say "5.0",
"5.0.1", or whatever variant you actually deployed.

Why aliases instead of a real 5_0 implementation

If you’ve watched the repo over the last year, you might remember
that 4_1 was the first version where a real new file got written
(feat(scrcpy): support server version 4.0 and 4.1 (#854)), with
new ScrcpyOptions4_0 and ScrcpyOptions4_1 types in libraries /scrcpy/src/4_0/ and 4_1/. The non-alias approach makes sense
when a server version adds a real protocol feature — for example, the
video / audio codec options that became first-class in 3_0.

For 5.0 and 5.0.1, nothing in the option shape the library models
has changed. Reading the upstream scrcpy release notes between 4.x
and 5.0 confirms this: the changes are bug fixes in the server
binary, not wire-protocol additions. So writing a new 5_0/ directory
that mirrored 4_1/index.ts line-for-line would have been a copy of
code that would immediately fall behind as soon as 4_1 got bugfixes
ported back. The alias keeps a single source of truth — if the
maintainer adds a new option to ScrcpyOptions4_1, both 4_1 and
5_0 and 5_0_1 get it for free.

The bipedal rewrite is mostly invisible, except where it isn’t

The commit that’s not in the headline but is by far the largest in the
diff is the rewrite of libraries/struct/src/bipedal.ts. bipedal
is the helper that turns a “bipedal generator” — a generator that
yields promise values to ask the host to await them — into an
ordinary function that returns a promise. It’s used by the
IndexedDB-transaction fix we wrote up two weeks ago: that’s how
waitRequest() lets a generator yield store.getAll() and get
back the resolved value.

The old implementation passed an Iterator<unknown, T, unknown> to a
helper called advance, which on each iteration inspected the yielded
value, and if it was a promise, attached .then() handlers that
wrapped the resolved value in { resolved: value } and the rejected
value in { error }
before passing it back into the iterator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// BEFORE (struct/bipedal.ts on beta.3)
function advance<T>(
iterator: Iterator<unknown, T, unknown>,
next: unknown,
): MaybePromiseLike<T> {
while (true) {
const { done, value } = iterator.next(next);
if (done) {
return value;
}
if (isPromiseLike(value)) {
return value.then(
(value) => advance(iterator, { resolved: value }),
(error: unknown) => advance(iterator, { error }),
);
}
next = value;
}
}

The generator helper that yielded the promise had to recognize the
sentinel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// BEFORE
function* <U>(
value: MaybePromiseLike<U>,
): Generator<
PromiseLike<U>,
U,
{ resolved: U } | { error: unknown }
> {
if (isPromiseLike(value)) {
const result = yield value;
if ("resolved" in result) {
return result.resolved;
} else {
throw result.error;
}
}
return value;
}

The new implementation just hands the resolved value back via
iterator.next(value) and the rejected value via
iterator.throw(error), which is the way every other generator
library (Koa, redux-saga, learning Generators) does it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// AFTER (struct/bipedal.ts on beta.4)
function advance<T>(
iterator: Generator<unknown, T, unknown>,
result: IteratorResult<unknown, T>,
): MaybePromiseLike<T> {
do {
if (result.done) {
return result.value;
}
if (isPromiseLike(result.value)) {
return result.value.then(
(value) => advance(iterator, iterator.next(value)),
(error: unknown) =>
advance(iterator, iterator.throw(error)),
);
}
result = iterator.next(result.value);
} while (true);
}

The generator helper gets correspondingly simpler:

1
2
3
4
5
6
7
// AFTER
function* <U>(value: MaybePromiseLike<U>): Generator<PromiseLike<U>, U, U> {
if (isPromiseLike(value)) {
return yield value;
}
return value;
}

Two things are worth noticing here:

  1. do { … } while (true) instead of while (true) { … } —
    the old loop body used next = value to re-assign and fall
    through to the next iteration, which meant the next-iteration
    value was set at the bottom of the loop. The new loop takes an
    IteratorResult argument (so the recursive calls pass
    iterator.next(value) rather than value) and has nowhere clean
    to put that bottom-of-loop assignment. do/while lets the body
    assign into the loop variable and the conditional sits at the
    bottom instead of the top.

  2. The type signature tightened too. The old type
    Iterable<unknown, T, unknown> for the iterator parameter was
    technically wrong — the third type parameter is the value passed
    back into the iterator via .next(), and it should match the
    yield type, not unknown. The new signature is
    Generator<unknown, T, unknown>, which (a) locks in that it’s a
    generator and not just an iterable, and (b) lets iterator.throw
    type-check without an extra cast.

    The old code had as never at the call site (fn.call(...) as never) because of this looseness. The new code drops it.

If you have a code path that does something exotic with the
sentinel — for example, inspecting { resolved, error } from a
generator you wrote yourself — that will break across the upgrade.
The IndexedDB-related call sites in @yume-chan/adb-credential-web
all go through the then: helper, not directly through advance, so
they keep working without changes. We checked libraries/ on the
beta.4 tree and no other file imports advance or the sentinel
shapes.

The dep churn is the boring half

The non-source half of the beta.4 diff is a pnpm-lock.yaml rewrite
(713 lines, ~ half insertions, ~ half deletions) plus package
metadata bumps across all twenty workspace libraries. The lockfile
diff is dominated by eslint-config → lint directory rename and
the prettier CI step:

1
2
3
toolchain/{eslint-config => lint}/eslint.config.js
toolchain/{eslint-config => lint}/package.json
toolchain/lint/run-lint.js

The interesting dep-side change is 991335b — chore: run prettier --check in CI — which pins the project to a single canonical
formatting. If you’re a ya-webadb contributor, expect PRs that fail
CI on a missing semicolon to come back with “run prettier.” This is
the maintainer moving from “please remember to format” to “the
formatter is in CI,” and 53f3b7a (fix: use consistent Node.js type names) is the kind of one-line cleanup that prettier would
have caught automatically — a reminder of why this matters.

What this means for webadb.online

We’re still on [email protected] in
package.json,
pending the v3.0.0 stable cut. The beta.4 changes don’t change that
plan, but they do firm up three things we were holding our breath on:

  1. WebUSB NotFoundError is now in a tagged release. This is the
    five-line fix from a5134b5
    we wrote up on 2026-10-04 — when a USB transfer is interrupted
    by a device unplug, the loop now resolves the promise cleanly
    instead of throwing. Our existing transfer-retry state machine
    already handles an undefined result, but we couldn’t tell anyone
    “this is fixed in upstream” until the tag landed.

  2. PR #871 is in a tagged release. Same story for the IndexedDB
    credential-storage fix. As of beta.4, a fresh ya-webadb install
    will not regress on the “credentials don’t persist” path that
    bit users in beta.3.

  3. scrcpy 5.0 wire compatibility is library-acknowledged. Anyone
    running scrcpy server 5.0 or 5.0.1 against our Screencast
    panel will hit the version-negotiation handshake cleanly; if we
    want to pin our latest to 5_0_1 in a future bump, the option
    shape is identical to 4_1 so the upgrade is a one-line change.

The bipedal rewrite doesn’t affect us at runtime — none of our
panels call into @yume-chan/struct directly — but it is the kind
of cleanup that makes the upstream maintainer likely to cut a stable
v3.0.0 sooner rather than later, which is the thing we’re actually
waiting on to drop the -beta.1 pin.

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 — 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.

Monday re-check — chromestatus row `6046077976444928` still hasn't moved, but the chromium tree is *unwinding* the Direct Sockets Private Network Access layer it shipped in M151

Last four Mondays — Sep 21, Sep 28, Oct 5, Oct 12 — have all been about the same surface: the
Permission Policy Merger: “direct-sockets-private” with “local-network” and “loopback-network”
row on chromestatus, and the way that row’s is_released: false has
contradicted the rest of the chromium ecosystem’s evidence that the
change shipped in Chrome 151. This Monday the chromestatus row itself is
byte-identical to the Oct 12 snapshot — updated.when is still
2026-06-22 13:32:10.563438, is_released is still false, the channel
calendar still targets stable 155 for 2026-10-06 / beta 156 / dev 157.
None of that has moved.

What has moved is the chromium source tree. Two commits — both by
Bhaskar Sharma, both directly motivated by the same chromestatus row —
landed in the last four months and effectively unwind the M151
implementation, in pieces. f1b445b8 removes the direct-sockets-private
permission policy (the very thing the chromestatus row is named after)
three months after the unpack-and-deprecate shipped, and a follow-up at
position main@{1703305} removes the never-launched DSPNA enterprise
policies that hung off the same layer. The chromestatus row is frozen;
the tree is moving in the opposite direction. That’s a more interesting
asymmetry than any of the four previous Mondays found, and it’s worth
walking through carefully.

There is also one small infrastructure change to record: the
chromestatus.com REST API path that this series has been calling via
/api/v0/features/<id> is unchanged in the JSON it returns, but the
per-feature HTML pages have been re-shipped as a fully client-side SPA
shell that no longer embeds the feature’s content in the static HTML
payload. The mirror at chromestatuslite.com is now the only surface
that serves a non-JS-readable version of these pages, which is a
practical change for anyone scripting the chromestatus pipeline.

What the chromestatus row still says

I pulled the same /api/v0/features/6046077976444928 payload the
previous four posts pulled, and it is byte-identical to the Oct 12
snapshot. The fields the previous posts called out:

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
id:                          6046077976444928
name: Permission Policy Merger: "direct-sockets-private"
with "local-network" and "loopback-network"
created.when: 2026-04-09 15:37:50.288704
created.by: [email protected]
updated.when: 2026-06-22 13:32:10.563438
updated.by: [email protected]
is_released: false
accurate_as_of: 2026-06-22 13:32:10.563438
shipping_year: 2026
chrome.status.text: Proposed
chrome.status.milestone_str: Proposed
chrome.desktop: 151
chrome.android: null
chrome.webview: null
chrome.ios: null
stages: 6 entries, stage_type 110/120/130/140/150/160,
intent_stage 7/1/2/4/3/5 respectively,
only stage 160 carries desktop_first: 151
ff.view.text: No signal
safari.view.text: No signal
webdev.view.text: No signals
star_count: 43
browsers.chrome.owners: [email protected], [email protected],
[email protected], [email protected]

Same numbers as Oct 12. The chrome.status.text: "Proposed" /
chrome.desktop: 151 contradiction is unchanged; the per-stage list is
unchanged; the updated.when has not been touched since Jun 22 — which
is itself significant, because the two chromium-source changes I am
about to discuss both post-date that timestamp. The chromestatus row
is frozen in time two months before the tree started cleaning up the
thing the row documents.

star_count: 43 is the only field that’s plausibly drifted, since the
Oct 12 post didn’t record it; I don’t have an earlier snapshot to
compare against. Worth noting for next week’s post.

f1b445b8: the temporary unpack is being removed

The Mar 20 2026 commit
aba71aaa5fa6b79b4d2a97b9e4e0013fc92948b9
(“Unpack direct-sockets-private to local-network and loopback-network”,
Bhaskar Sharma, Cr-Commit-Position: refs/heads/main@{#1602704}) is the
implementation that shipped in M151. It added the temporary back-compat
unpacking that takes a manifest declaring direct-sockets-private=[self]
and synthesizes local-network=[self] and loopback-network=[self]
underneath, plus a DevTools deprecation warning telling the developer to
update the manifest. That shim was always intended to be temporary —
the commit message explicitly says “temporarily automatically unpacked”
and a TODO(b/492476083): Remove backward compatibility code by Chrome Milestone 151 lived in
chrome/browser/web_applications/isolated_web_apps/iwa_permissions_policy_cache.cc
(per the chromium review page for f1b445b8‘s parent commit).

Three months after it shipped, on Fri Jun 12 16:45:03 2026, the
follow-up landed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
commit f1b445b81bac2adf82ec6ba55c53bfa9eecd72a3
author Bhaskar Sharma <[email protected]>
date Fri Jun 12 16:45:03 2026
tree 66083c5bda91cd5aa0befb3bfc3d74a77685215e
parent 3439854fb05218bb870f872b35b83f98e31604e4

Remove direct-sockets-private permission policy

This CL removes "direct-sockets-private" permission policy along with
its default unpacking to "local-network" and "loopback-network"
permission policies.

Fixed: 492476083
Change-Id: Ic2cac25c8a22e749b48ea65b63dd32cefa7e0fca
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7845254
Reviewed-by: Vlad Krot <[email protected]>
Reviewed-by: Danil Somsikov <[email protected]>
Reviewed-by: Nasko Oskov <[email protected]>
Reviewed-by: Andrew Rayskiy <[email protected]>
Reviewed-by: Ari Chivukula <[email protected]>
Reviewed-by: Simon Hangl <[email protected]>
Commit-Queue: Bhaskar Sharma <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1646037}

The diff itself spans 23 files across the IWA permissions-policy stack,
the Direct Sockets service implementation, the Blink
permissions_policy_feature.mojom IDL, and the WPT test fixtures for
direct-sockets-private. The full file list, as enumerated in the
chromium review page:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
chrome/browser/ash/app_mode/test/kiosk_iwa_permissions_browsertest.cc
chrome/browser/devtools/protocol/devtools_protocol_browsertest.cc
chrome/browser/direct_sockets/direct_sockets_apitest.cc
chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc
chrome/browser/web_applications/isolated_web_apps/isolated_web_app_entitlements_browsertest.cc
chrome/browser/web_applications/isolated_web_apps/isolated_web_app_permissions_policy_browsertest.cc
chrome/browser/web_applications/isolated_web_apps/iwa_permissions_policy_cache.cc
chrome/browser/web_applications/isolated_web_apps/key_distribution/preload/key_distribution.textproto
chrome/browser/web_applications/isolated_web_apps/key_distribution/proto/key_distribution.proto
chrome/browser/web_applications/isolated_web_apps/runtime_data/iwa_entitlements.cc
chrome/browser/web_applications/isolated_web_apps/runtime_data/iwa_entitlements_unittest.cc
content/browser/direct_sockets/direct_sockets_service_impl.cc
content/browser/direct_sockets/direct_sockets_udp_browsertest.cc
content/browser/direct_sockets/firewall_hole_browsertest.cc
content/public/browser/direct_sockets_delegate.h
services/network/public/cpp/permissions_policy/permissions_policy_features.json5
services/network/public/cpp/permissions_policy/permissions_policy_features_unittest.cc
services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom
third_party/blink/public/devtools_protocol/domains/Page.pdl
third_party/blink/web_tests/http/tests/inspector-protocol/network/direct-sockets/resources/socket-default.php
third_party/blink/web_tests/http/tests/inspector-protocol/network/direct-sockets/resources/udp-socket-success.php
third_party/blink/web_tests/wpt_internal/isolated-permissions-policy-stable/permissions_policy.https.html
third_party/blink/web_tests/wpt_internal/isolated-permissions-policy/permissions_policy.https.html

The core changes are in
services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom
(where the direct-sockets-private enum entry is removed from the
allowlist), the permissions_policy_features.json5 (where the feature
descriptor is deleted), and
chrome/browser/web_applications/isolated_web_apps/iwa_permissions_policy_cache.cc
(where the unpack-and-deprecate warning was the load-bearing logic).

What this means for the chromestatus row at
6046077976444928:
the row is titled “Permission Policy Merger: ‘direct-sockets-private’
with ‘local-network’ and ‘loopback-network’”
. The merger logic is
the unpack-and-deprecate. With f1b445b8 landing, that logic is gone
from the tree. The chromestatus row is now a description of code that
no longer exists — it’s a row documenting a permission-policy change
whose implementation has been deleted before the row itself was ever
updated to reflect it shipped.

This is the new and interesting asymmetry: the chromestatus row
describes a permission-policy change. The permission-policy change was
implemented in aba71aaa (Mar 20 2026), shipped in M151, and then
unimplemented by f1b445b8 (Jun 12 2026). The chromestatus row was
last touched on Jun 22 2026, ten days after the removal commit, and the
touch was not a substantive update — the updated.when field didn’t
change because the row’s JSON shape is the same; it was probably an
internal pipeline touch, not a content refresh. So today the row reads
“new or changed feature, status Proposed, desktop 151” and the code it
references has already been deleted from the implementation it was
referencing.

The DSPNA enterprise-policy cleanup, three months after that

There’s a second commit in the same vein that landed even later, at
position refs/heads/main@{#1703305}. I couldn’t resolve its full SHA
from the public surface (the buildbot description only lists the
Cr-Commit-Position), but the relevant metadata is enough to cite it
cleanly:

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
Commit:      refs/heads/main@{#1703305}
Review URL: https://chromium-review.googlesource.com/c/chromium/src/+/8423981
Author: Bhaskar Sharma <[email protected]>
Bug: 393460553
Change-Id: I4c9f58095d5549ce4ccc14dda8ad10cb274a26a7
TAG: agy
CONV: 38edcb27-8f89-467f-a686-7237d46b26ea

Remove unlaunched DirectSocketsPrivateNetworkAccess enterprise
policies

The Direct Sockets Private Network Access (DSPNA) enterprise
policies (DefaultDirectSocketsPrivateNetworkAccessSetting,
DirectSocketsPrivateNetworkAccessAllowedForUrls, and
DirectSocketsPrivateNetworkAccessBlockedForUrls) were marked
future_on and never launched. Following the merger of DSPNA into
Local Network Access (LNA) and removal of the direct-sockets-private
permission policy, remove these unlaunched enterprise policies
and mark the DirectSocketsPrivate blink permission policy enum
entry as deprecated. The main Direct Sockets enterprise policies
(DefaultDirectSocketsSetting, DirectSocketsAllowedForUrls,
DirectSocketsBlockedForUrls) remain intact.

BYPASS_POLICY_COMPATIBILITY_CHECK=Policies were future_on and
never launched.

This is a sister commit to f1b445b8, two commit-positions later on
main. Three things happen in it:

  1. The three future_on DSPNA enterprise policies are deleted —
    DefaultDirectSocketsPrivateNetworkAccessSetting,
    DirectSocketsPrivateNetworkAccessAllowedForUrls, and
    DirectSocketsPrivateNetworkAccessBlockedForUrls. These are the
    chromeenterprise.google/policies/ entries that the Oct 12 post
    noted were still listed but irrelevant (they were never enabled).
  2. The DirectSocketsPrivate blink permission policy enum entry is
    marked as deprecated.
    This is a half-step toward a future commit
    that will also remove the enum entry entirely; for now, the
    Chromium build still compiles with it present in the mojom, just
    tagged deprecated so the next removal pass doesn’t break anyone’s
    downstream code.
  3. The main Direct Sockets enterprise policies are left intact —
    DefaultDirectSocketsSetting, DirectSocketsAllowedForUrls,
    DirectSocketsBlockedForUrls. Those did ship (in M131) and are
    the production enterprise surface for the feature.

The commit’s bug entry (393460553) is the same family of “DSPNA is
going away” bugs that have been around since the LNA merger in M142.
The justification chain — future_on and never launched — is the same
one the chromium team uses for any policy that was written but never
enabled in any release. BYPASS_POLICY_COMPATIBILITY_CHECK is the
build-flag the policy compatibility verifier checks to allow
never-launched policies to be deleted without going through the
deprecation cycle. This is the cleanest possible removal: the policies
shipped never, and they were never going to ship, so they’re being
removed before anyone could possibly have shipped an enterprise config
that depends on them.

The effect on chromestatus: the chromestatus row at
6046077976444928
doesn’t mention the DSPNA enterprise policies at all — that’s a
permission-policy merger row, not an enterprise-policy row, and
enterprise policies are tracked in a different chromestatus table that
I haven’t been following in this series. So the chromestatus row stays
untouched by this second commit. But the Blink permission policy layer
the chromestatus row does describe is now in the state of “the
permission has been removed from the tree, the Blink enum entry is
deprecated, the enterprise policies that depended on it have been
removed.” Three pieces of state-machine cleanup, all tied to the
underlying chromestatus row, none of which the chromestatus row
reflects.

The asymmetry has flipped direction

Oct 5’s post wrote “the chromestatus row is genuinely stale; treating
that as ‘this row is stale’ is the right call.” Oct 12’s post said
“the row is being intentionally left as a stub because the feature has
shipped and documentation tracking is no longer the team’s priority.”
Both inferences were true at the time. What’s new this Monday is that
the chromestatus row is now stale in a more specific sense: it
documents code that has been removed.

If you read 6046077976444928
today, you see:

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.
This change replaces the existing “direct-sockets-private” permission
policy.

The first sentence is true today. The second sentence — “This change
replaces the existing ‘direct-sockets-private’ permission policy” — was
true when the row was written (April 2026) and was true when it shipped
(M151, June 2026). It is not true as of Jun 12 2026, when
f1b445b8 removed direct-sockets-private from the tree entirely.
After f1b445b8 there’s no “existing direct-sockets-private“ policy
to replace; there’s nothing to unpack, because the thing being unpacked
has been deleted.

The chromestatus row doesn’t reflect any of this. It’s frozen on its
own internal sense of what shipped, and the implementation team has
moved on. That’s a real kind of stale — not “the row is being
intentionally left as a stub because the feature has shipped” (Oct 12’s
framing), but “the row documents a transient migration step, and the
migration is already over.”

The infra change: chromestatus.com is now SPA-only

A smaller infrastructure finding worth pinning while we’re here: the
chromestatus.com per-feature HTML pages used to embed the feature’s
metadata in the static HTML payload — title, summary, motivation,
standards & signals, all visible in the raw HTML bytes before any
JavaScript ran. The Oct 12 post and earlier posts in this series
relied on that property: curl https://chromestatus.com/feature/<id> | grep "<h2>" was enough to recover the content.

As of this week, the page payload is a 3.7 KB SPA shell with a
<chromedash-app> element and an empty <chromedash-toast> and a
single <script src="/static/dist/shared.js?v=46150ef" type="module">
that hydrates the page client-side. There is no title metadata, no
<h2>Motivation</h2>, no summary, no JSON-in-script — the SPA
component fetches its own data from a separate API endpoint after the
JS bundle runs. Curl returns 200 with the shell; grep returns nothing.

The mirror at chromestatuslite.com is unaffected and still serves the
content as static HTML. So this series can keep going, but anyone
who’s been scraping chromestatus.com directly needs to either:

  • Switch to chromestatuslite.com (which is what’s been used as the
    ground-truth in the post bodies anyway), or
  • Switch to the /api/v0/features/<id> JSON endpoint that has been
    the canonical source throughout this series.

Both work today; the chromestatus.com HTML page no longer does. The
implication for chromestatus.com’s reliability as a documentation
surface is small in the short term (the JSON endpoint is what the SPA
itself uses, so it has to keep working), but it’s a meaningful
maintenance commitment — the JSON endpoint is now load-bearing for every
client-rendered feature page on the site.

SSM revert is still where Oct 12 left it

The Mar 11 2026 commit
3076e5f5
that reverted the SSM-for-Direct-Sockets add is still reverted,
with no follow-up commit visible on chromium/main. That is the
same state the Oct 12 post recorded, and is the same state any of the
four Mondays since Sep 21 would have recorded. No public re-land has
happened.

The
chromestatus SSM row at 6208452397498368
should eventually move when SSM re-lands (it will populate a milestone
when the production code goes back in). For now it’s still frozen on
its Feb 21 updated.when, which the Oct 12 post already noted was the
date before the revert, and is now eight months stale instead of
the seven it was last week. The Blink component page is still the
canonical surface for the in-the-tree / out-of-the-tree state of that
feature.

What this means for webadb.online

No code change to webadb this week either. The f1b445b8 removal
matters less for webadb than the aba71aaa unpack did when that
landed: webadb’s IWA manifest already declares local-network and
loopback-network (the post-merger correct shape, as the Sep 14 post
noted), so the unpacking logic that aba71aaa added didn’t help
webadb and the removal logic in f1b445b8 doesn’t hurt webadb.
webadb never declared direct-sockets-private, so it never had a
dependency on either the unpack or its removal.

The DSPNA enterprise-policy cleanup at main@{1703305} is similarly
inert for webadb: webadb’s own IWA manifest declares
local-network=[self] (not direct-sockets-private), and the
enterprise policies that were removed are the
DefaultDirectSocketsPrivateNetworkAccessSetting /
DirectSocketsPrivateNetworkAccessAllowedForUrls /
DirectSocketsPrivateNetworkAccessBlockedForUrls policies that no
admin would have set anyway (they were future_on and never launched).
For any enterprise admin reading this: if you have a config that sets
any of those three, the config is now a no-op (the policies don’t
exist) and the chromium team is not providing migration support because
the policies never shipped — the cleanup CL’s
BYPASS_POLICY_COMPATIBILITY_CHECK rationale explicitly says “Policies
were future_on and never launched.” Switch to the main Direct Sockets
policies (DefaultDirectSocketsSetting etc.) or to the LNA policies
(LocalNetworkAccessAllowedForUrls /
LocalNetworkBlockedForUrls etc.), which are the replacement surface.

Three things are worth pinning for future Mondays:

  1. The chromestatus row at 6046077976444928 is now documenting
    deleted code.
    Future Monday re-checks should expect it to stay
    frozen on updated.when: 2026-06-22 and is_released: false
    indefinitely — there is no implementation left for it to track.
    If the team ever decides to delete the row entirely (which would be
    the consistent next step), that itself would be a meaningful
    event to record.
  2. The Blink enum entry for DirectSocketsPrivate is now deprecated,
    but not removed.
    The next commit in the family will likely be a
    pure removal pass on permissions_policy_feature.mojom; when that
    lands, watch for a fresh BYPASS_POLICY_COMPATIBILITY_CHECK commit
    tagged permissions_policy_feature.mojom to land in chromium’s
    recent commits list for the Direct Sockets subcomponent.
  3. /api/v0/features/<id> is now the only surface on
    chromestatus.com that serves programmatic data
    — the SPA shell
    that the per-feature HTML page renders is empty without the API
    data, and the API data isn’t inlined. Anyone scripting the
    chromestatus pipeline should already be on the API path (this
    series has been since Sep 28), but it’s worth re-pinning in light
    of the SPA migration.

Concrete practical takeaway for anyone building on top of Direct
Sockets right now: the M151 unpack-and-deprecate shim is gone from
the tree
. If your IWA manifest still declares
direct-sockets-private=[self] (older IWAs from before M145), you
have a much shorter deprecation runway than the chromestatus row
suggests — by the time Chrome ships the version that includes
f1b445b8‘s removal, the policy will simply not exist, and any
manifest that depends on it will fail the permissions-policy check at
load time. Update the manifest to declare local-network=[self] and
loopback-network=[self] explicitly before that happens.

Concrete practical takeaway for the chromestatus pipeline specifically:
the four-week streak of “row frozen, tree frozen” is over. The row
is still frozen; the tree is no longer. That is the asymmetry worth
carrying forward.