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 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().
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.
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 = newDistributionStream(packetSize, true); const sendStream = newSendWritableStream(pool, socket, mtime); void distributeStream.readable.pipeTo(sendStream).catch(NOOP);
// after — wrapper awaits the pipe AND the writer's close return { writable: newDelayedCloseWritableStream( distributeStream.writable, distributeStream.readable.pipeTo(sendStream), ), getbytesWritten() { 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:
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.
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.
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 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.
return { writable: newMaybeConsumable.WritableStream({ write(chunk) { return writer.write(chunk); }, asyncclose() { 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:
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 beforesendStream 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:
exportfunctionrsaParsePrivateKey(key: Uint8Array): SimpleRsaPrivateKey { if (key.length < RsaPrivateKeyDOffset + RsaPrivateKeyDLength) { thrownewError( "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.
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:
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.
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.
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.
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 = newTangoIndexedDbStorage(); forawait (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
exportfunction createTransaction<T>( database: IDBDatabase, storeName: string, callback: (transaction: IDBTransaction) => T, ): Promise<T> { returnnewPromise<T>((resolve, reject) => { const transaction = database.transaction(storeName, "readwrite"); // ... try { result = callback(transaction); if (result instanceofPromise) { thrownewError("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.
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 instanceofPromise) 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:
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() { returnthis.#openDatabaseCore(); // no ??=, no memoization }
// Option B: keep the cache, drop the closes asyncsave(privateKey, name) { const db = awaitthis.#openDatabase(); awaitcreateTransaction(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:
Error: callback must not be an async function from createTransaction in any IndexedDB credential flow → Bug #1.
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:
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.
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.
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.
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 reald, 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:
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.
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.
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.
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:
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)
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:
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.
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.
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:
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.
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.
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)
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;
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.
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:
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 asScrcpyOptions5_0 } from"./4_1/index.js";
// libraries/scrcpy/src/5_0_1.ts (new in beta.4) export { ScrcpyOptions4_1 asScrcpyOptions5_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)) { returnyield value; } return value; }
Two things are worth noticing here:
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.
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:
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:
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.
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.
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.
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:
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:
The behavioral changes underneath the API churn are three:
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.
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.
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() { returnthis.#openDatabaseCore(); // no ??=, no memoization }
// Option B: keep the cache, drop the closes asyncsave(privateKey, name) { const db = awaitthis.#openDatabase(); awaitcreateTransaction(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():
…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() { returnthis.#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.
// 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:
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:
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.
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.
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.
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.
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)
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
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:
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.
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:
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:
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.
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:
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.
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.
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:
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:
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.
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:
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:
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:
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).
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.
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.
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:
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.
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.
/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.