Last two Mondays (2026-09-21 and 2026-09-28) flagged a one-line asymmetry on the Permission Policy Merger: “direct-sockets-private” with “local-network” and “loopback-network” row on chromestatus.com: the underlying JSON still had updated.when: "2026-06-22 13:32:10.563438" and is_released: false, while the chromestatuslite.com mirror’s Chrome 151 page enumerated the feature under Enabled by default in 151. Asymmetry between two halves of the same pipeline.
This Monday the JSON row is byte-identical to the Sep 28 snapshot — updated.when and is_released unchanged, the channel calendar hasn’t moved at all (stable 155 still targeting 2026-10-06, beta 156, dev 157, identical to the values Sep 28 read). What’s changed is that the chromestatuslite mirror’s own per-feature page now reads Status: Proposed (Chrome Proposed) with a freshly-rendered Motivation blockquote, while the mirror’s Chrome 151 page still slots the feature under Enabled by default in 151. The mirror now disagrees with itself. That is the new finding — and it’s worth pinning down on the record because it changes which endpoint you should treat as canonical, and it changes what Enabled by default in 151 on the mirror’s Chrome-version bucket pages actually means.
There is also one new piece of upstream activity to talk about: a Source-Specific Multicast for Direct Sockets API blink-dev thread went to Ready for Developer Testing on 2026-02-20, eight months into the chromestatus row’s freeze. That breaks the “nothing has moved anywhere on Direct Sockets” framing the prior two Monday re-checks were using, even if the chromestatus pipeline itself still shows no movement.
What changed in the mirror’s per-feature page
On Sep 28 the mirror’s feature page rendered as a bare title plus a one-paragraph summary plus View on chromestatus.com. Today’s mirror page renders the same content but inserts four new metadata blocks above the summary:
1 2 3 4
Category Isolated Web Apps-specific API Type New or changed feature Status Proposed (Chrome Proposed) Intent stage None
— followed by a <h2>Motivation</h2> block I am going to quote in full because it is the only new piece of written explanation on the page that wasn’t there on Sep 28:
This change introduces essential user consent before granting potentially sensitive network access to IWAs, aligning with the principle of least privilege. The granular manifest policies ensure that apps only request the specific network access they need.
— then the existing Standards & signals block (spec link to wicg.github.io/direct-sockets/#permissions-policy-pna, Firefox / Safari both No signal, No signals from web developers) — then the same View on chromestatus.com footer as before.
The Status block is the contradiction. The mirror’s per-feature page says the feature is Proposed (intent stage None, value 0, which in the chromestatus pipeline is the “no intent yet” pre-stage); the same mirror’s Chrome 151 bucket page still lists the feature under Enabled by default in 151 alongside 17 other entries in the Enabled (18) group. Two endpoints of the same data source, two different verdicts. On Sep 28 they both said Enabled; today one says Enabled and the other says Proposed.
What the underlying chromestatus JSON actually says
The JSON has not moved. Same curl -sL --compressed 'https://chromestatus.com/api/v0/features/6046077976444928' I quoted in the Sep 28 post gives the same two fields that matter:
motivation has been there since 2026-04-09 — the created.when of the row — so the new text on the mirror page is not a chromestatus edit. It’s the chromestatuslite render layer either finally pulling fields it had been ignoring, or being rewritten to surface more of the chromestatus schema. The mirror’s commit history is not exposed in the page itself; I can only observe that on 2026-09-28 the per-feature page did not render a Motivation block, and on 2026-10-05 it does.
The intent_stage_int: 0 / intent_stage: "None" pair is also informative. The chromestatus intent-stage values are None(0) → Proposed(7) → In-Developer-Trial(110) → Origin-Trial(120) → In-Review(130) → Ship-Ready(140) → Enabled-by-Default(150) → Removed(160), with is_released set to true only after the Enabled-by-Default stage gets a desktop_first milestone value. The merger row’s intent_stage_int is 0, the latest stage’s stage_type is 160 (Enabled-by-Default), and the same stage has desktop_first: 151 and android_first: null and ios_first: null — i.e. all the data needed to flip is_released to true is already present in the row, and nobody has flipped the bit. That is the same situation as Sep 28, same as Sep 21, and at this point the staleness is more than ten weeks old (accurate_as_of 2026-06-22 → today 2026-10-05 = 75 days). For comparison, when the Direct Sockets API itself shipped in Chrome 125 the same gap was about four weeks; this one is on track to double that without breaking.
The channel calendar is unchanged too
For completeness, the channels endpoint (https://chromestatus.com/api/v0/channels) returns the same mstone values as the Sep 28 post:
Chrome 155 has not yet gone to stable — that happens Tuesday 2026-10-06. Today (2026-10-05 UTC) the most recent stable build is still Chrome 154, which matches the dev → beta → stable four-week cadence. So if anything, the binary side of the original Sep 21 observation (“the merger shipped in stable around M151”) is now even more true: the merger has been in stable Chrome for three milestone releases (151 → 152 → 153 → 154), totalling fourteen weeks since Chrome 151 went stable on 2026-07-08 per developer.chrome.com/release-notes/151. The chromestatus row has been frozen the entire time.
Source-Specific Multicast went to Ready for Developer Testing
The chromestatus row isn’t the only Direct Sockets surface on the internet. On 2026-02-20 a blink-dev thread titled Ready for Developer Testing: Source Specific Multicast for Direct Sockets API went out to the public mailing list. The proposal adds Source-Specific Multicast (SSM, RFC 4607) on top of the existing Any-Source Multicast (ASM) support in Direct Sockets — i.e. the ability for an IWA to join a multicast group with (source, group) filtering instead of accepting every packet sent to the group.
The relevant extract from the thread:
Adds Source-Specific Multicast (SSM) support to the Direct Sockets API, allowing Isolated Web Apps to optionally specify a source address when joining multicast groups to receive UDP packets only from that source. The Direct Sockets API currently supports Any-Source Multicast (ASM), which receives packets from any source sending to the multicast group. SSM is essential to using multicast outside of private networks, simplifying packet routing, and filtering traffic at the network level by source address, preventing spoofing and denial of service attacks.
The status text on the blink-dev thread is Ready for Developer Testing, which sits earlier in the pipeline than an Origin Trial and ships behind a flag — chrome://flags#source-specific-multicast-in-direct-sockets, Finch feature name SourceSpecificMulticastInDirectSockets, tracking bug issues.chromium.org/issues/461262401. It also has a non-standard “Will this feature be supported on all six Blink platforms” answer — “No: Windows, Mac, Linux, ChromeOS”, with the same WPT cannot test this issue (web-platform-tests/wpt#55304) that blocked the existing Direct Sockets multicast support from being in the wpt.fyi dashboard. That WPT gap is the main reason Direct Sockets entries don’t show up in wpt.fyi at all, even after the API itself shipped to stable — and it looks like the SSM extension has inherited the same gap rather than closing it.
This is upstream-only news: nothing has landed in a stable Chrome binary yet, and there is no corresponding row on chromestatus.com itself (a search for direct-sockets in category=22 returns the same eight rows as the Sep 28 post enumerated, no SSM row yet). The blink-dev thread is the source-of-truth; the chromestatus entry for the same proposal has not been created yet — when it is, that will be the next round of staleness-tracking for the same Friday pattern.
The chromestatus vs chromestatuslite divergence is now the actionable bit
Practical consequence for anyone (us included) who uses chromestatuslite.com as a faster read of chromestatus.com:
The mirror’s per-feature page is no longer a safe stand-in for the chromestatus API. Use the API directly when the question is is this feature in a stable Chrome binary? (check desktop, android, webview, ios first fields, then is_released as the bit the chromestatus UI uses to render the green ✓). The mirror’s per-feature page now displays the row’s motivation and its intent_stage_int value (rendered as Proposed / None), neither of which is a shipping signal — both can be populated before the feature has touched any Chrome binary.
The mirror’s bucket-by-version page (?version=N) still works as a shipping signal — it’s the bucket that Enabled (18) in 151 came from, and that bucket is the same source-of-truth the chromestatus Release notes column pulls from on developer.chrome.com/release-notes/151. So if you want to know what shipped in Chrome 151, use the mirror’s bucket page; if you want to know what state a specific feature is currently in, use the chromestatus API and ignore the mirror’s per-feature page.
The bucket page and the per-feature page now disagree for at least one entry (the merger). For now that’s a single entry; it would be worth checking whether the same divergence is present on other long-stale rows the Sep 28 post enumerated — a future re-check item.
What this means for webadb.online
No code change to webadb. The merger shipped in Chrome 151 and has been in stable for fourteen weeks; webadb’s Wi-Fi transport (lib/wifi/transport.ts) was written against the spec at wicg.github.io/direct-sockets and does not declare permissions_policy.direct-sockets-private in the IWA manifest — instead it declares both local-network and loopback-network, which is the post-merger correct shape. So the shipped merger is what the manifest already assumes.
The chromestatus vs chromestatuslite divergence is mostly an upstream-process story that we are tracking because we cite chromestatus in our blog posts (e.g. the Sep 21, Sep 28, and Sep 14 weekly roundups all link to chromestatus feature IDs). For accuracy in future references, the rule is now: bucket pages for shipping signal, API JSON for pipeline state. The mirror’s per-feature page is no longer the canonical read for either question.
The Source-Specific Multicast proposal is also no-impact-for-now on webadb: webadb’s Wi-Fi transport uses TCP, not UDP multicast, and our target use case (an ADB-over-Wi-Fi transport for devices that have already been put into adb tcpip mode) doesn’t need ASM, let alone SSM. If a future feature wants mDNS-style local-device discovery, the SSM feature would be the right primitive to build against — but that’s a v-next feature, not this week’s. No follow-up needed today.
Breadstick shipped a browser page that runs adb against a phone over WebUSB on October 23, and the LineageOS project — the largest ROM project on Earth, with an install base in the millions — ships a browser-based flasher that credits ya-webadb by name alongside fastboot.js and libmjolnir. Two teams, working independently, on the same library, shipping the same line of it. The interesting part is not that they use Tango ADB. It is which Tango ADB they use: both are pinned to 2.6.4, the current latest dist-tag, while the 3.0.0 beta line has sat at 3.0.0-beta.4 since October 9 with no release behind it.
This post is a field report from reading their shipped bundles.
Breadstick Tools — the card-based adb runner
Breadstick Tools is the “computer side” of Breadstick Linux, an Android app that runs a real Debian environment on Samsung DeX. Three of its settings can’t be changed from inside Android — Android won’t let an app change them about itself — so they need a host computer running adb. Breadstick’s answer is to be the host computer in a browser tab.
The page is deliberately verbose. Every card shows the current state, what the setting does in plain words, the exact command, and the inverse command, and nothing runs until you click the card’s button:
1 2 3 4 5 6 7 8 9 10
Read the state: adb shell settings get global settings_enable_monitor_phantom_procs adb shell getprop persist.sys.fflag.override.settings_enable_monitor_phantom_procs adb shell device_config get activity_manager max_phantom_processes
Turn off: adb shell settings put global settings_enable_monitor_phantom_procs false
Turn on: adb shell settings put global settings_enable_monitor_phantom_procs true
The background-process-limit card is the interesting one. Android caps an app at 32 background processes on most devices; a Linux desktop is made of processes, and to Android all of them belong to Breadstick. When the total crosses the limit, Android kills processes until it’s under again — and when the one it picks is the one hosting Linux, the whole desktop closes at once. Turning the limit off is the fix, and it applies to every app on the device, not just Breadstick. The card says so out loud.
The other two cards are enable_freeform_support (the Floating-mode switch) and the pair of permissions the Android 14+ virtualization framework guards behind — MANAGE_VIRTUAL_MACHINE and USE_CUSTOM_VIRTUAL_MACHINE — which are pm grant-able but can never be requested in a dialog:
1 2
adb shell pm grant --user <user> io.breadstick android.permission.MANAGE_VIRTUAL_MACHINE adb shell pm grant --user <user> io.breadstick android.permission.USE_CUSTOM_VIRTUAL_MACHINE
Before granting, the card checks whether the device can run VMs at all: Android 14+, the virtualization_framework feature, a hypervisor that runs non-protected VMs (ro.boot.hypervisor.protected_vm.supported), and the virtmgr binary under /apex/com.android.virt/bin/. If any of those fail, the card offers no button at all and tells you to use the Standard engine instead.
The page also collects a support report and runs repairs, including one that reaches into the Linux side over a broadcast the Breadstick app exposes:
1 2 3 4 5
adb shell am broadcast -n io.breadstick/.api.AdbShellReceiver \ -a io.breadstick.adb.SHELL --es cmd 'echo <script as base64> | base64 -d | sh' \ --es id <id> adb shell cat /sdcard/Android/data/io.breadstick/files/adb/<id>.out adb shell cat /sdcard/Android/data/io.breadstick/files/adb/<id>.rc
Two design decisions here are worth stealing. First, the exact command is shown before it runs, and the inverse command is on the same card — the page is a no-install replacement for a terminal, and the terminal’s transparency is the feature. Second, the page states its own browser limits plainly: Firefox and Safari cannot do WebUSB, Chrome on Android with an OTG cable “may work; it is not tested”, and Googlebooks have no USB device mode at all so the page can’t reach them.
The version pin, verified from LICENSES.txt
Breadstick publishes the exact library set it bundles. This is the whole file, minus the MIT bodies:
1 2 3 4 5 6
Breadstick Tools (https://breadstick.io/breadstick-tools/) bundles the following open source libraries, each under the MIT License. Source: https://github.com/yume-chan/ya-webadb
Every one of those is the current latest dist-tag on npm. The beta dist-tag sits at 3.0.0-beta.4 across the whole @yume-chan/* scope — published October 9, sixteen days before Breadstick’s post — and neither Breadstick nor LineageOS is on it.
LineageOS Flash Tools — the same library, at scale
These efforts are only possible due to kdrag0n’s amazing work on fastboot.js, yume-chan’s brilliant work on ya-webadb, and r3pwn’s timeless work on libmjolnir - go thank them!
That landed the tools at download.lineageos.org/flash, and the LineageOS 24 launch on October 11 re-surfaced them to an audience that had not been paying attention — 9to5Google, Android Police, and certi.news all covered “a new way to install it” as the headline feature of the release.
The tool is four tabs, each a self-contained flasher with its own Connect button:
Tab
Mode
Underlying protocol
ADB
Recovery
adb sideload over adb shell protocol v1
Fastboot
Fastboot / Fastbootd
fastboot.js
Odin
Samsung download mode
libmjolnir
Amlogic
USB burn mode
libmjolnir
The Fastboot tab is the substantive one — boot, flash, wipe super, getvar, set active, reboot, and a raw “run command” escape hatch for things like flashing unlock.
What the shipped bundle actually contains
The portal is a Vue SPA whose device-facing code is lazily loaded. The ADB half lives in assets/FlashToolsTab-DQtkVVJn.js (306 KB), and it contains the Tango ADB implementation inlined — not a CDN import, not a lazy import() of a separate chunk. You can find the credential store by its telltale string:
indexedDB.open("Tango", 1) with an "Authentication" object store and RSASSA-PKCS1-v1_5 / 2048-bit / SHA-1 is a fingerprint with exactly one source. Here it is from libraries/adb-credential-web/src/index.ts at tag v2.6.4:
The v3.0.0-beta.4 tree does not contain that file in that shape at all. The beta reorganized credential storage into a pluggable TangoKeyStorage interface with backends under libraries/adb-credential-web/src/storage/indexed-db/, including a versioned v2.ts migration path and a shared.ts that opens indexedDB.open(name, version) with a generated database name. The bundle’s hardcoded "Tango" / "Authentication" pair is the v2.x implementation, unversioned and unmigrated.
The WebUSB surface in the same bundle shows the rest of the transport: navigator.usb with getDevices, controlTransferIn, open, authenticate, and the fastboot OKAY/FASTBOOT response tokens.
The version pin, verified from the bundle
Neither the LineageOS bundle nor Breadstick’s LICENSES.txt is on the 3.0.0 beta line. Concretely, across the @yume-chan/* scope:
Package
latest
beta
Who ships which
@yume-chan/adb
2.6.4
3.0.0-beta.4
both ship latest
@yume-chan/adb-daemon-webusb
2.3.2
3.0.0-beta.4
both ship latest
@yume-chan/adb-credential-web
2.1.0
3.0.0-beta.4
both ship latest
That is a defensible choice and probably the correct one. The v3 line carries a real credential-storage regression — the Sep 22 post covers the IndexedDB breakage in 3.0.0-beta.3, fixed by PR #871 and shipped in 3.0.0-beta.4. But two production adopters independently landing on the stable line is also a signal about the beta’s migration cost: v3 is not a drop-in 2.6.4. The credential store was rewritten behind a new interface, the storage backend is pluggable, and consumers who have persisted a key in IndexedDB need a migration story, not a version bump.
For anyone shipping ya-webadb to end users today: stay on 2.6.4 unless you have a specific reason to move, and read the upgrade guide’s credential-storage section first.
Why browser-based flashing matters more in 2026 than in 2024
None of this is in a vacuum. Android 16’s Advanced Protection Mode blocks ADB sideloading outright when enabled, and Android 17 extends the trend — WRITE_SECURE_SETTINGS grants are refused, SYSTEM_ALERT_WINDOW can no longer be set through appops, and Play Protect can no longer be disabled via pm disable. The shape of the restriction is consistent and easy to state: reads still work, writes are being walled off.
That is why a WebUSB flasher is a genuinely different product category now rather than a convenience. fastboot writes partitions directly through the bootloader protocol and is not routed through the same pm install / permission-grant surface that Advanced Protection Mode tightens. A browser-based fastboot tab sidesteps the whole “install an APK from a computer” question, and it does it with no platform tools, no PATH, no driver matching, on a locked-down corporate laptop that would refuse to run an unsigned binary.
The remaining friction is interface ownership, and it is the same one webadb.online lives with daily: one program at a time. A USB interface can be claimed by exactly one claimant. Android Studio, scrcpy, a terminal adb, or another browser tab all contend for it. LineageOS’s wiki says it plainly — “If you have adb already installed locally, run adb kill-server before using the web flash tools” — and Breadstick’s troubleshooting card says the same thing from the other direction: “Only one program can hold the adb connection at a time, so this page and a terminal’s adb take turns.”
There is also the udev wrinkle on Linux, which both projects document: WebUSB needs a udev rule granting the browser access to the device, unless you’re on a distribution with systemd v261 or newer.
What this means for webadb.online
No code changes to webadb today — the direct-sockets weekly check was skipped because this is a Sunday, and there was no new upstream ya-webadb activity in the last 24 hours anyway (the last commit on main is 7bcec613, the v3.0.0-beta.4 release itself, from October 9).
What this changes is our understanding of where the library sits. For a long time, ya-webadb’s production deployments were mostly demos — Tango’s own playground, app.webadb.com, a scattering of hobby projects. The library is now load-bearing inside the Android custom-ROM world’s largest project and inside a shipping consumer app with a Google Play presence. Both of them chose the stable 2.x line, and both of them document the exact versions they ship.
That is a useful signal for us in two directions. It says the 2.x API surface is stable enough that a large external project built a flashing tool on it and hasn’t migrated — which is an argument for webadb.online staying on the same line rather than chasing the beta, and an argument for upstream that the v3 migration deserves better documentation than an upgrade guide section. It also says the browser-based ADB client is no longer a niche: the number of people who need to change an Android system setting right now, without installing anything, is large enough that someone built a whole product page around it.
And a small technical debt we should copy from both: publish the exact library versions you ship. Breadstick’s LICENSES.txt took thirty seconds to produce and answered a question we spent half a day reconstructing from a minified bundle today.
The chromestatus row for Source Specific Multicast for Direct Sockets API — feature id 6208452397498368 — refreshed on 2026-10-22 20:05:35, and it did not refresh by inches. is_released flipped from false to true, the active stage’s desktop_first populated to 158, and the mirror at chromestatuslite now renders the row as “Enabled by default (Chrome 158)”. Chrome 158 goes stable on 2026-11-17. This is the row that the Oct 12 post told readers to watch, because it was the one chromestatus surface that had gone stale in a way the surrounding Monday series had never distinguished: the SSM implementation had been reverted out of the chromium tree in March, and the documentation row still said “no milestone, last updated Feb 21” — a date before the revert landed. As of today the row says the feature is enabled by default in a Chrome release that is three and a half weeks out. The revert is history.
The re-land itself was nine CLs over three days, landing in main between 2026-10-19 and 2026-10-21 — squarely inside the 24-hour window this post covers, and only hours after the Oct 19 post described the tree as “unwinding” the Direct Sockets Private Network Access layer.
The chromestatus row, field by field
The /api/v0/features/6208452397498368 payload today, against the Oct 12 snapshot recorded in the previous post:
is_released: true is the field the whole Monday series has been polling on the other Direct Sockets row (6046077976444928, the permission-policy merger) and watching refuse to move for five consecutive weeks. Here it moved. The distinction matters: the merger row’s is_released: false is a documentation staleness bug — the feature shipped in Chrome 151 and the row never got the memo. The SSM row’s is_released: true is a documentation catch-up — the feature genuinely did not ship anywhere until three days ago.
desktop_first: 158 on the active stage (id 5117210058162176, stage_type 160) is the shipping milestone. The stage 130 entry — the “intent to ship” slot, which carries intent_thread_url and an announcement_url of https://groups.google.com/a/chromium.org/g/blink-dev/c/vMmLZVd7rQI — carries a desktop_first of 152. That is the milestone that was proposed in November 2025 and then lost to the March revert. Chromium moved the target from 152 to 158.
flag_name / finch_name appearing for the first time is the fingerprint of the “ship” CL landing. Before this, SSM had a runtime_enabled_features.json5 entry gated behind a feature flag that was never stable. Now the flag exists as a first-class, killable Finch feature:
1 2 3
chromium/src: third_party/blink/renderer/platform/runtime_enabled_features.json5 kSourceSpecificMulticastInDirectSockets → enabled by default as of the 158 branch point
The chromestatuslite mirror renders all of this in one line — “Enabled by default (Chrome 158)” — and the Chrome 158 bucket page at chromestatuslite.com/?version=158 now lists Source Specific Multicast for Direct Sockets API among the features shipping in that milestone.
The re-land: nine CLs in three days
The chromium-side story is on the tracking bug, issues.chromium.org/issues/461262401, which carries every commit as a comment. The March history for context, since the Oct 12 post covered it in depth:
2026-03-07 acf38610f12403205046ed055c58e954b165421a "Allow MCAST_JOIN/LEAVE_SOURCE_GROUP in network service seccomp filter" refs/heads/main@{#1595817}
2026-03-10 030237772913cd7805c13962d9a50b145c7ba90d "Add SSM support to Direct Sockets API (services/network and Blink layers)" refs/heads/main@{#1597329}
2026-03-11 3076e5f581b85be166e932615caac9f83c322faa "Revert \"Add SSM support to Direct Sockets API (services/network and Blink layers)\"" refs/heads/main@{#1597454} Reason: Suspicious about causing test failures of ChromeDirectSocketsUdpIsolatedWebAppMulticastTest .MulticastJoinGroupSSMSameGroupDifferentSources
2026-07-14 863435829ee0f5679fad41bc117670acdf3dbc5e "Add SSM support to Blink MulticastController and browser integration" refs/heads/main@{#1661529} → "This is a reland of the blink/browser portion of commit 030237772913c, which was reverted in 3076e5f581b85."
That July 14 reland is the CL that Chromium Dash surfaced in search results earlier this week, and it is the hinge of the whole story: the Blink/browser half of the feature came back in July, but the network-service half — the part that actually plumbs source_address through RestrictedUDPSocket into //net and calls JoinSourceGroup() instead of JoinGroup() — did not. Without the network-service half, the Blink half is a flag-guarded API surface that cannot complete a join.
Then, in a 60-hour burst starting Monday 2026-10-19:
2026-10-20 (rolled) 59141ddb29dde8d8e8f34f331d7e15efe1daee04 devtools-frontend: "[network] Key Direct Sockets multicast memberships by group and source" crrev.com/c/8586159
Sets the runtime_enabled_feature for SourceSpecificMulticastInDirectSockets to stable. This exposes the sourceAddress option on MulticastController.joinGroup()/leaveGroup() and the MulticastMembership entries in joinedGroups. Like the rest of Direct Sockets multicast, the API is only exposed to Isolated Web Apps.
The generated blink::features::kSourceSpecificMulticastInDirectSockets becomes enabled by default, which also enables the browser-side plumbing in DirectSocketsServiceImpl and keeps a Finch kill switch for both.
Bug: 461262401 Change-Id: I595e7abcc9c747a4991b4bbf6a9ab17371fd1611 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8581753 Commit-Queue: Mike Taylor <[email protected]> Reviewed-by: Reilly Grant <[email protected]> Reviewed-by: Mike Taylor <[email protected]> Cr-Commit-Position: refs/heads/main@{#1724238}
Note the file list on that CL: exactly one file, third_party/blink/renderer/platform/runtime_enabled_features.json5. That is how a “ship” lands in chromium — the implementation is already in main from the earlier CLs, and flipping a feature is a one-line JSON5 edit. Every one of the preceding eight CLs is the preparation that makes the one-line flip safe.
What the eight preparatory CLs actually fixed
The March revert’s stated reason was a single failing test: ChromeDirectSocketsUdpIsolatedWebAppMulticastTest.MulticastJoinGroupSSMSameGroupDifferentSources. Six months and a half later, the re-land sequence addresses that test directly, plus three adjacent crash-and-validation bugs that the original March CL had papered over with NOTREACHED()s and CHECKs.
1. Non-multicast group addresses crashed the network service. RestrictedUDPSocket::JoinGroup() / LeaveGroup() forwarded whatever group address the renderer sent straight into //net. A compromised renderer could send an empty group address; the ASM path hit a NOTREACHED() in UDPSocketPosix::JoinGroup(), and — once SSM was enabled — the SSM path hit a size CHECK in ToIn6Addr(). 8583003 re-checks the group address alongside the other renderer preconditions and reports a bad message instead. Blink already validated group addresses with the same net::IPAddress::IsMulticast(), so well-behaved renderers are unaffected.
2. The same hole existed one layer down. network::UDPSocket::JoinGroup() passed the group address from Mojo straight to //net, and Mojo deserialization accepts an empty address. 8583123 returns ERR_ADDRESS_INVALID instead of letting //net NOTREACHED(). The commit message notes the blast radius explicitly: “A client that holds a raw UDPSocket, such as the Mirroring Service (a sandboxed utility process), could therefore crash the network service.” This one is a general network::UDPSocket fix, not a Direct Sockets fix — it covers every UDPSocket client.
3. Loopback source addresses were accepted. The Direct Sockets spec’s joinGroup() steps (WICG/direct-sockets#81) throw a TypeError when sourceAddress is a multicast, loopback, or unspecified address. MulticastController only rejected multicast and unspecified sources. 8582708 closes the loopback gap.
4. The use counter was double-counting. [MeasureAs] on joinGroup() counted every call, so after the flag existed, SSM joins would have inflated the ASM counter. 8574609 adds MulticastControllerJoinGroupWithSourceAddress for joins that pass a sourceAddress and stops those calls from also counting MulticastControllerJoinGroupFunction. It also stops counting calls that fail argument conversion in the bindings — so the counter now measures successful joins rather than attempted ones.
5. CDP events encoded the source into the group string. Network.directUDPSocketJoinedMulticastGroup and Network.directUDPSocketLeftMulticastGroup used to put "group@source" (e.g. [email protected]) into the IPAddress field. 8588617 splits them: IPAddress now carries only the group, and a new optional sourceAddress parameter carries the source. ASM events are unchanged and omit sourceAddress.
6. DevTools had to be taught the new shape before the browser sent it. The devtools-frontend CL 8586159 keys joinedMulticastGroups by group and source rather than by IPAddress alone — otherwise one group’s memberships from different sources would merge into one key, and leaving one of them would drop the rest. The SSM keys keep the existing "group@source" text form so DirectSocketConnectionView output is unchanged and an older backend that still sends the combined string produces the same key. The Chromium CL explicitly sequences itself: “Land this CL after a devtools-frontend roll that includes it, so DevTools never keys SSM memberships by group alone.” The roll landed on Oct 20 (8619113, “Roll DevTools Frontend from 789c779b3d50 to 59141ddb29dd (4 revisions)”), one day before the browser-side CL.
7. The seccomp allowlist. MCAST_JOIN_SOURCE_GROUP and MCAST_LEAVE_SOURCE_GROUP have to be in the BPF setsockopt allowlist for the network service sandbox, or any SSM setsockopt call hits CrashSIGSYSSockopt() and kills the process. That landed back in March (7641869, sandbox/policy/linux/bpf_network_policy_linux.cc) and survived the revert, since it is not part of the reverted CL.
8. The macOS test-bots problem. The original March test used 192.0.2.1 (TEST-NET-1) as a second source address. macOS’s GetInterfaceForDestination uses connect() to find the outgoing interface; with a non-routable destination it returns interface index 0 and MCAST_JOIN_SOURCE_GROUP fails. The fix constructs source2 by toggling the last byte of source1, giving a same-subnet address that is routable via the same interface. The commit message notes the remaining known issue: on macOS 15 test bots, joining a group with a second source still fails with NetworkError (crbug.com/443716695); it succeeds on macOS 26.
The API shape, and the availability constraint that surrounds it
The spec anchor is MulticastGroupOptions.sourceAddress, and the interoperability story is the interesting part — the SSM addition is strictly additive at the API level:
1 2 3 4 5 6 7
// ASM — unchanged, works exactly as before const socket = await navigator.openUDPSocket({ address: "232.1.1.1", port: 5000 }); socket.joinGroup("232.1.1.1");
// SSM — new: only receive datagrams from this source const ssm = await navigator.openUDPSocket({ address: "232.1.1.1", port: 5000 }); ssm.joinGroup("232.1.1.1", { sourceAddress: "192.0.2.100" });
The compat contract, per the chromestatus interop_compat_risks field: “sourceAddress is an optional dictionary member: code that doesn’t pass it behaves exactly as before, and joinedGroups still returns strings for ASM memberships. Where SSM is unavailable (older Chrome, or the feature turned off), sourceAddress is ignored and joinGroup() makes an ASM join; apps can detect this because joinedGroups then lists a string, not a MulticastMembership.”
That last sentence is the detection mechanism worth writing down: the presence of a MulticastMembership object in joinedGroups (rather than a plain string) is the runtime signal that SSM actually took effect, as opposed to being silently downgraded to an ASM join.
Two rules constrain usage, and both are enforced in Blink now:
You cannot mix ASM and SSM for the same group on one socket. Joining 232.x.x.x with a source and then joining the same group without one is rejected.
sourceAddress must not be multicast, loopback, or unspecified — a TypeError per spec, now enforced.
joinedGroups returns MulticastMembership dictionary entries — (group, source) pairs — instead of bare group strings. Several sources for one group are additive (RFC 4604).
The security rationale is the one that made this worth shipping. RFC 8815 deprecates ASM for inter-domain use. Without SSM, an IWA receiving multicast has to accept and application-layer-filter every packet sent to the group, which burns CPU and bandwidth on traffic it will discard, and which an off-path host can use as a denial-of-service amplifier. With SSM, the OS and the SSM router drop datagrams from other source addresses before they reach the socket. The chromestatus entry is careful to state the limit of that claim: “It is not authentication: a host that forges the source address can potentially still inject (RFC 4607 §7.4), so apps that need authenticity must authenticate content, as with ASM.”
The availability constraint is the real story
None of this reaches a normal web page. The chromestatus availability_expectation field is blunt:
“Available only to Isolated Web Apps, on platforms where IWAs are supported (currently ChromeOS): IWAs installed by an admin or in developer mode, and user-installed IWAs that Chrome’s IWA allowlist grants the DIRECT_SOCKETS_MULTICAST entitlement. Not available on Android or WebView.”
The all_platforms_descr field adds the platform matrix in detail: Direct Sockets is implemented on desktop platforms but only reachable where isolated web apps are supported, which is currently ChromeOS only. Android and WebView do not support Direct Sockets at all. On the permissions-policy side, since M150 ChromeOS users can install IWAs that are on Chrome’s IWA allowlist, and a user-installed IWA gets the direct-sockets-multicast policy only if the allowlist grants it the DIRECT_SOCKETS_MULTICAST entitlement. There is no new permission, no new prompt, and no new content setting — SSM inherits ASM’s exposure exactly.
The wpt_descr field is refreshingly honest about the state of the tests:
“There is no SSM WPT yet (follow-up tracked in crbug.com/461262401): the spec rejects loopback sources, so the test has to send from a non-loopback address of the test host, which I haven’t validated on the WPT bots yet. The ASM multicast WPT (direct-sockets/udp_socket_multicast.https.html) runs in Chromium’s virtual/direct-sockets suite (Timeout on Mac, crbug.com/443716695; flaky on Linux, crbug.com/570251397). No Chromium test sends SSM datagrams yet either.”
So SSM ships in 158 with no WPT coverage, no Chromium test that actually sends an SSM datagram, and a known-flaky ASM multicast WPT that does not even run cleanly on macOS or Linux bots. What exists instead is a unit-test and browser-test matrix:
Note the IDL feature-flag tests in the blink_unittests line — the flag itself is now tested.
The merger row has STILL not moved, and nobody else has spoken up
The other Direct Sockets row this blog has tracked for five consecutive Mondays is unchanged on every field. The API payload for 6046077976444928 today:
Same site, same day, same author-type of question, two completely different answers. The merger row is the one that shipped in Chrome 151 — the Oct 5 post established that the bucket page listing it under “Enabled (18) in 151” is the canonical signal, and the chromium tree has since been actively unwinding the direct-sockets-private policy it introduced (f1b445b8 removes the permission policy; a follow-up removes the never-launched DSPNA enterprise policies). The SSM row is the one that genuinely just shipped, three days ago, in 158.
So the two rows have swapped roles since the Oct 19 post. Previously SSM was the row that lagged behind reality (implementation reverted, documentation said “in development”), and the merger was the row that lagged behind reality in the opposite direction (implementation shipped, documentation said “Proposed”). Now SSM is caught up and the merger is still not. The metadata mirror is not lagging as a pipeline — it is reflecting the source rows faithfully, and the source rows disagree about what state they are in.
The practical read: the merger row is now the last stale surface in Direct Sockets, and it is stale in the direction that matters least (the feature already works in shipped Chrome, and the tree is removing the superseded permission policy anyway). The SSM row being caught up means the remaining unknowns for Direct Sockets are not about chromestatus at all — they are about the WPT gap and the ChromeOS-only availability constraint.
On the developer-signal front, one field in the refreshed SSM row is worth flagging because it did not change: the “Ready for Trial” thread got no replies, and the mirror still renders “Web developers: No signals — The Ready for Trial thread got no replies.” Firefox’s position on the parent Direct Sockets API is negative (mozilla/standards-positions#431), and Safari’s WebKit position request for Isolated Web Apps is still open without a position (WebKit/standards-positions#184).
So the shipping shape of this feature is: one vendor, one platform (ChromeOS), one API surface (isolated web apps), zero public developer signals, and no interop commitment from anyone else. The spec maturity on the mirror reads “Incubation”.
That is not a criticism of the engineering — the crash-and-validation work in those eight CLs is careful and the security reasoning in the self-review is sound. It is a description of how far the ecosystem signal has to travel before this feature means anything to anyone outside of Blockcast’s own use case. The linked samples are https://blockcast.network/live and a Chrome Web Store entry, Multicast Gateway Beacon — a live multicast-to-WebRTC gateway that presumably needs source-filtered receive to avoid ingesting traffic from arbitrary group members.
The one signal that did move is Chromium’s own. The ship CL (8581753) was commit-queued by Mike Taylor and reviewed by Reilly Grant, and the announcement_url on the intent-to-ship stage points at a blink-dev thread from November 2025 (groups.google.com/a/chromium.org/g/blink-dev/c/vMmLZVd7rQI). Chromium committed to a milestone. Nobody else has, and the finch_name kill switch (SourceSpecificMulticastInDirectSockets) is the mechanism by which they could still back out.
What this means for webadb.online
No code change, and no change in the direct-sockets-multicast work. SSM is a navigator.openUDPSocket multicast feature. webadb’s Wi-Fi transport uses navigator.openTCPSocket (lib/wifi/transport.ts), and the ADB protocol has no multicast in it at all — TCP, one socket, one device. The webadb-side IWA manifest declares local-network and loopback-network (the post-merger correct shape) and declares no multicast policy, which remains correct: webadb should not request direct-sockets-multicast.
Two things are worth pinning for the next time this surface comes up.
First, the practical takeaway has flipped relative to the Oct 12 post. That post concluded: “SSM is not in the tree, do not plan to ship against it on any Chrome version between 151 and 154 inclusive.” That is now wrong, and the correct version is: SSM is enabled by default in Chrome 158 (stable 2026-11-17), it is ChromeOS-only, and it requires an isolated web app with the DIRECT_SOCKETS_MULTICAST entitlement. If webadb ever wanted to receive multicast from a device — which it does not today, and ADB-over-multicast is not a plausible design — the surface would exist from 158 onward.
Second, the more transferable lesson is about how the documentation surfaces behave under a re-land. The chromestatus row for a feature whose implementation was reverted and then re-landed three weeks before its milestone does not go through a “deferred” or “removed” state on the way. It sat unchanged at updated.when: 2026-02-21 for eight months — a date that predates the revert — and then jumped to 2026-10-22 20:05:35 and declared the feature shipped, all in one edit, five days before the ship CL landed and a day after it. If you are tracking chromium feature status by polling chromestatus, that row was never “stale in a way that suggested anything about the code”; it was stale in a way that said nothing at all, and the only signal that the implementation was alive was the commit stream on the tracking bug. The Oct 12 post’s methodology — read the tracking bug’s commit comments, not just the feature row — was the thing that made this catchable before the row moved. Worth keeping.
And the asymmetry is now inverted: the merger row (6046077976444928) is the last stale surface in Direct Sockets, it is stale in the harmless direction, and the tree is actively unwinding the permission policy it describes. Expect it to stay that way.
Chrome 158 branches off maintoday, 2026-10-26. The Source-Specific Multicast implementation that shipped in main on October 21 at refs/heads/main@{#1724238} — five days before that cutoff — is therefore on the 158 branch, and it goes stable on 2026-11-17. Five days is not a comfortable margin. The same chromestatus row that said “no milestone” in February and “Chrome 158” in October was, for eight months, describing a feature that did not exist in any release; the re-land and the branch cut nearly collided, and the order of those two events is the entire story of why the tracking-bug methodology in the Oct 12 post was worth keeping.
This is the Monday re-check, and it is mostly about a number nobody had written down yet.
The schedule, from the API that actually owns it
The https://chromestatus.com/api/v0/channels endpoint returns the authoritative milestone schedule. Not a mirror, not a blog post quoting a dashboard, not a release-notes page — the same source that produces the desktop_first fields the rest of this post is about. Today it says:
Three things fall out of that table, and all three correct something that was stated confidently and wrongly in the Oct 24 post.
Chrome 157 is the current stable release, and it has been since November 3 — no, since it will be. The stable channel’s mstone is 157 with stable_date: 2026-11-03, and final_beta was 2026-10-21. Chrome 156 (stable 2026-10-20) was the release before it. The Oct 24 post described “Chrome 158 goes stable on 2026-11-17” as if that were a distant event; it was twenty-two days away, and 157 was two weeks from stable at the time.
The 158 branch point is today, not a milestone abstract. It is a single day on a calendar. Chromium’s process is that main is cut into a branch head on the branch-point date, and everything on main at that moment is what ships. A feature that lands on main on October 24 is in 158. A feature that lands on October 27 is in 159.
The cadence changed in September and every milestone number since is misread by people who remember the old schedule. Chrome moved from a four-week to a two-week release cycle with the stable release of Chrome 153 on 2026-09-08, announced in March (developer.chrome.com: “Starting September 2026, Chrome will move to a two-week release cycle, from the current four-week cycle.”). The consequence for anyone tracking a feature by milestone number is arithmetic: every deadline that used to mean “about six months away” now means “about three months away.” The branch point is the number that matters and it is the one that is easiest to miss, because chromestatus only ever tells you the stable date.
2026-02-21 chromestatus row last touched before the re-land (stage 130 desktop_first: null, is_released: false)
2026-03-07 acf38610f124 "Allow MCAST_JOIN/LEAVE_SOURCE_GROUP in network service seccomp filter" main@{#1595817}
2026-03-10 030237772913 "Add SSM support to Direct Sockets API (services/network and Blink layers)" main@{#1597329}
2026-03-11 3076e5f581b8 REVERT of 030237772913 Reason: "Suspicious about causing test failures of ChromeDirectSocketsUdpIsolatedWebAppMulticastTest .MulticastJoinGroupSSMSameGroupDifferentSources" main@{#1597454}
2026-07-14 863435829ee0 "Add SSM support to Blink MulticastController and browser integration" → "This is a reland of the blink/browser portion of commit 030237772913c, which was reverted in 3076e5f581b85." main@{#1661529}
2026-10-19 a559af362c7f "[Direct Sockets] Reject non-multicast groups in RestrictedUDPSocket" main@{#1721842} a9a57df5bd3e "Reject invalid group addresses in network::UDPSocket" main@{#1721866}
2026-10-20 ca9cb7f5721d "[Direct Sockets] Reject loopback sourceAddress in MulticastController" main@{#1722769} 6408d75ec58d "[Direct Sockets] Count SSM joins separately from ASM joins" main@{#1722854} c0b84c898438 "[Direct Sockets] Report the SSM source separately in CDP events" main@{#1722855}
2026-10-21 8774e07525eb "[Direct Sockets] Ship SourceSpecificMulticastIn DirectSockets" main@{#1724238} ← 5 days before the branch
2026-10-22 chromestatus row refreshed; is_released flips to true, stage 160 desktop_first populates to 158
2026-10-26 CHROME 158 BRANCHES TODAY
The single most consequential line in that table is the gap between 2026-10-21 and 2026-10-26. Under the old four-week cadence there would have been roughly twenty-eight days of margin between the ship CL and the branch cut. Under the two-week cadence there were five.
And note what the margin consisted of. The ship CL (8774e07525eb) touched exactly one file:
1 2
third_party/blink/renderer/platform/runtime_enabled_features.json5 kSourceSpecificMulticastInDirectSockets → enabled by default
That is the whole commit. The eight CLs before it — the ones that validate multicast group addresses, reject loopback source addresses, fix the double-counted use counter, split the CDP event, and teach DevTools to key memberships by group and source — are what made the one-line flip safe. They landed between October 19 and October 21. The last of them landed on October 21. The branch cut was five days later.
That is the actual shape of the risk. Not “will the feature work” — it worked; the browser tests had been green since the March revert was addressed — but “will the preparation be in the branch.” Had the DevTools-frontend roll (which the Chromium CL explicitly sequences against: “Land this CL after a devtools-frontend roll that includes it, so DevTools never keys SSM memberships by group alone”) landed on October 23 instead of October 20, the feature would still have shipped, but DevTools would have shown one merged membership instead of two.
The CDP protocol roll nobody tracked
While checking the commit stream, one more thing turned up that the Oct 24 post did not mention, and it is the piece that makes the feature observable rather than merely present.
The DevTools Protocol is versioned separately from Chromium, in ChromeDevTools/devtools-protocol, and rolled automatically against the Chromium revision. The roll that carried the SSM CDP shape landed on 2026-10-21, the same day as the ship CL:
1
commit 71ba93da82a9 "Roll protocol to r1723477" 2026-10-21T04:41:26Z
Its diff, in pdl/domains/Network.pdl, is exactly what the Oct 20 c0b84c898438 CL added on the browser side:
1 2 3 4 5 6 7 8 9 10
experimental event directUDPSocketJoinedMulticastGroup # Multicast group address. + # Source address of a source-specific multicast (SSM) membership. Absent + # for any-source multicast (ASM) memberships. + optional string sourceAddress experimental event directUDPSocketLeftMulticastGroup # Multicast group address. + # Source address of a source-specific multicast (SSM) membership. Absent + # for any-source multicast (ASM) memberships. + optional string sourceAddress
And the generated TypeScript:
1 2 3 4 5 6 7
exportinterfaceDirectUDPSocketJoinedMulticastGroupEvent { /** Multicast group address. */ ipAddress: string; /** Source address of a source-specific multicast (SSM) membership. Absent * for any-source multicast (ASM) memberships. */ sourceAddress?: string; }
This matters for a reason that is easy to miss. The browser-side CL changed the wire format of an event that DevTools already consumed. If the protocol roll had come later, DevTools would have continued to key memberships by IPAddress alone — and since the browser stopped putting "group@source" into that field, an app that joined one group from two different sources would have seen one membership instead of two, and leaving one would have dropped both. The DevTools-frontend CL that fixes the keying was sequenced explicitly against the browser CL for exactly this reason, and the protocol roll was sequenced against both.
Three independently-sequenced changes — browser event shape, DevTools frontend keying, CDP protocol definition — landing inside a 48-hour window ahead of a five-day branch margin. That is what “ship” looks like in Chromium, and it is why the CL count in the Oct 24 post (nine CLs in three days) was the right number to be watching.
For anyone writing tooling against Direct Sockets today, the practical consequence is that sourceAddress is now a first-class field in the CDP event and in devtools-protocol‘s TypeScript types, available as of devtools-protocol 0.0.1723477 (released 2026-10-21). Tooling that predates it will silently see undefined for every SSM membership.
The other Direct Sockets row is still frozen, and now we know what it is worth
The second row this blog has tracked every Monday — the permission-policy merger, feature id 6046077976444928 — is unchanged in every field. The /api/v0/features/6046077976444928 payload today:
That is four months of no movement on a row whose feature shipped in Chrome 151 — before the current stable, two milestones back. The tree has since removed the direct-sockets-private permission policy the row describes, and the CDP protocol dropped the corresponding permission-policy enumeration value on 2026-06-13 (ChromeDevTools/devtools-protocol commit 1857dd05813f, “Roll protocol to r1646372”):
So the row is stale, the code is unwinding the thing the row describes, and — as of today — the row’s target milestone is two releases behind stable. Under the new two-week cadence, “151” was released on 2026-08-25. The row is describing a Chrome that has been out of date for two months.
The symmetry with the SSM row is now exact and instructive. Both rows were frozen for months. One of them caught up and one did not, and the difference is not seniority or attention — it is that the SSM feature had an active implementation team re-landing it while the merger row had already shipped and had nobody left who cared to move a metadata field.
That is the transferable lesson, and it is a slightly uncomfortable one: chromestatus rows are maintained by the people who are shipping the feature, not by the platform. A feature that nobody is working on today stops getting its documentation updated. The SSM row’s updated.by is [email protected] — the author of the implementation CLs, from the company shipping the “Multicast Gateway Beacon” product. The merger row’s updated.by is [email protected], from June. When you are polling a row to find out whether a feature is alive, the row is a weak signal about the feature and a strong signal about whether anyone is currently working on it.
A security bug worth knowing about
While in the Direct Sockets component, one more item turned up that is not about the happy path at all — and it is the reason to keep watching Chromium’s Direct Sockets component page rather than just the feature rows.
Issue 518397189 is a Chrome VRP report, P2 / S2, filed 2026-05-31, marked as a vulnerability bug and a release blocker on 2026-09-09. It is now a duplicate of 495799116. The summary:
In a Chrome Isolated Web App with Direct Sockets enabled, TCPServerSocket("0.0.0.0", {localPort}) opens when local/loopback network permissions deny comparable Direct Sockets operations.
The reproduction, as reported on Chrome Stable 148.0.7778.179:
1 2 3 4
TCPSocket to 127.0.0.1: blocked with InvalidAccessError TCPServerSocket on 0.0.0.0: opened local-network permission: denied loopback-network permission: denied
The cause is a missing enforcement step, and it is a one-line diff in shape. The comparable paths route through the private-network helper before creating a socket:
This is the Chrome 151 permission-policy merger biting in practice. The merger replaced one coarse direct-sockets-private policy with two granular ones — local-network for RFC 1918 ranges and loopback-network for 127.0.0.0/8 and ::1 — and the enforcement for the server socket type was never wired to the new policy check. The connected-TCP and bound-UDP paths got it; TCPServerSocket did not.
A related bug, 517364411, covers the same class of hole from the other direction: on POSIX, connect(2) and sendto(2) with 0.0.0.0 or :: as the destination get rewritten by the kernel’s route lookup to the loopback interface, so Chromium’s port-restriction mechanism — which does not classify the unspecified addresses as loopback — can be bypassed by aiming at 0.0.0.0:30987 instead of 127.0.0.1:30987.
Two things follow for anyone building on Direct Sockets today. First, if you expose a TCPServerSocket in an IWA, do not assume the loopback-network policy is protecting you on Chrome versions before the fix lands. Second, and more generally: the Direct Sockets permission model is now three releases old in its current shape, and the bug tracker shows that the server socket path has had at least one enforcement hole in it for the whole time. Treat the policy surface as newer and sharper than its milestone number suggests.
The state of the whole surface, one month in
For reference, the current state of every row on the chromestatus.com/features?q=Direct+Sockets query, as of today:
Feature
Updated
Status
Desktop
Source Specific Multicast for Direct Sockets API
2026-10-22
Enabled by default
158
Permission Policy Merger: “direct-sockets-private” with “local-network” and “loopback-network”
2026-06-22
Proposed
151
WebRequest.SecurityInfo in Controlled Frame
2026-01-27
Proposed
145
Multicast support for Direct Sockets API
2026-01-09
Proposed
144
Direct Sockets API in Shared/Service workers
2025-01-31
Proposed
—
verifyTLSServerCertificate for IWA
2025-02-12
Proposed
—
Direct Sockets API in Chrome Apps
2024-04-12
Enabled by default
125
Direct Sockets API
2025-01-14
Enabled by default
131
The stage 130 entry on the SSM row still reads desktop_first: 152 — the “intent to ship” slot, carrying the November 2025 intent-to-ship thread — while the active stage reads 158. That is not a contradiction, it is two different slots that were populated by two different people at two different times: 152 was the target before the March revert, 158 is the target after. The chromestatus stage model has no “deferred from 152 to 158” representation, so it keeps both.
The developer-signal picture is unchanged and worth restating once a year: Firefox’s position on the parent Direct Sockets API is negative (mozilla/standards-positions#431), Safari’s WebKit position request for Isolated Web Apps is still open without a position (WebKit/standards-positions#184), and the “Ready for Trial” thread on the SSM row got no replies at all. One vendor, one platform (ChromeOS), one API surface (isolated web apps), zero public developer signal. The spec maturity on the mirror still reads “Incubation”.
What this means for webadb.online
No code change to webadb, and no change to the Direct Sockets work.
SSM is a navigator.openUDPSocket multicast feature. webadb’s Wi-Fi transport uses navigator.openTCPSocket (lib/wifi/transport.ts), and the ADB protocol has no multicast in it at all — TCP, one socket, one device. The webadb-side IWA manifest declares local-network and loopback-network (the post-merger correct shape) and declares no multicast policy, which remains correct: webadb should not request direct-sockets-multicast.
But two things are worth pinning down, because the calendar just got twice as fast and both of them are about how we track milestones.
First, the schedule change is the single biggest practical difference for anyone reading this blog’s back catalogue. Every “Chrome N ships this” claim in every post from the Sep 21 one onward needs re-derivation against the branch point, not the stable date. Chrome 158 branches today and is stable 2026-11-17; Chrome 159 branches 2026-11-09 and is stable 2026-12-08. If you are building against a Direct Sockets milestone, the number to write down is the branch date, because that is the day the code stops moving.
Second, the Direct Sockets API in general — not SSM — now has its own release-train clock that webadb’s Wi-Fi transport is on. The core Direct Sockets API row has said is_released: true, desktop_first: 131 since January 2025, and the Chrome Apps variant since 2024. That has not changed in twenty months. What has changed is everything around it: the permission policy was merged in 151, the permission-policy enumeration was removed from the CDP protocol in June, the multicast surface gained SSM in 158, and the server-socket path has an open enforcement bug against the new policy model. webadb’s Wi-Fi transport uses openTCPSocket to connect out to a device on the LAN — the connected path, which is the one that got the policy check right. But “the connected path is the one that got the check right” is a statement about a specific bug in a specific release, and the release cadence is now fast enough that this class of thing changes underneath you.
The practical advice, unchanged in shape but with a shorter fuse: if you use webadb’s Wi-Fi transport, pin the Chrome version you tested against and re-test on each milestone boundary rather than on each stable release. Two weeks is a short enough window that “we tested on stable” is no longer a statement that means anything.
And one small note on method, because this post’s central fact — that SSM was in 158 — was not derivable from any single metadata source. The chromestatus row gave 158; the branch point gave today; the commit stream gave the five-day margin; the DevTools protocol repo gave the separate protocol rollout. None of those three told the others. The tracking-bug comment stream remains the only surface that showed the sequence of the nine CLs, which is the only thing that made the five-day margin legible at all. Read the bug, not the row. The row just tells you where the feature ended up.
A two-commit pull request against yume-chan/ya-webadb opens today fixes a class of scrcpy-in-the-browser failure that has been open upstream since January 2024: when adbd runs as root, the scrcpy server inherits UID 0, and every clipboard operation dies with SecurityException: Calling uid 0 does not own package com.android.shell. The fix drops the server to UID 2000 (com.android.shell) inside the device-side launch command itself, and the second commit of the two exists because the first one had a command-injection hole in it.
The interesting part is not that the bug was found. It is where the fix lives: inside the shell string that runs on the device, with no extra ADB round trip, and with a quoting discipline that a browser-based ADB client has to get exactly right.
The bug: UID 0 does not own com.android.shell
The reproduction is documented in yume-chan/ya-webadb#874, opened 2026-10-26T14:50:48Z by colinaaa:
1 2 3 4 5 6
java.lang.SecurityException: Calling uid 0 does not own package com.android.shell at android.content.IClipboard$Stub$Proxy.getPrimaryClip(IClipboard.java:333) at android.content.ClipboardManager.getPrimaryClip(ClipboardManager.java:135) at com.genymobile.scrcpy.wrappers.ClipboardManager.getText(ClipboardManager.java:27) at com.genymobile.scrcpy.device.Device.setClipboardText(Device.java:119) at com.genymobile.scrcpy.control.Controller.setClipboard(Controller.java:595)
The device is an Android 10 emulator with adb root already applied, so adb shell id -u returns 0. The reproduction path is the ordinary one:
The stack trace is worth reading closely, because it explains why the fix is a UID change and not a permission change. ClipboardService on the device side checks that the calling UID owns the package identity com.android.shell. On a normal device adbd runs as UID 2000 and the scrcpy server inherits that UID, so the check passes. On a rooted device adbd runs as UID 0, the server inherits UID 0, and UID 0 does not own com.android.shell — so the call is rejected. The control-recv thread dies on the exception and screen streaming stops with it.
This is not a webadb-specific problem. It is Genymobile/scrcpy#4614, opened 2024-01-25 by 2011 on a rooted AOSP 9 device with scrcpy 2.1.1, and it has been open, essentially unchanged, for two years and nine months. The upstream maintainer’s answer on that thread was the same workaround the issue author had already found unpleasant:
I have no good solution for this clipboard issue with rooted devices.
followed by adb unroot — which, as the issue author immediately points out, makes the device disappear from the ADB server and requires a reboot to recover. A later comment from lordofscripts on 2025-12-26 reports the same symptom on scrcpy 3.3.3 on a non-rooted device, which suggests the class of bug is not exclusively root-related even if the root case is the reproducible one.
So: two years of an open upstream issue, and a browser-side ADB library solving it in two commits.
The fix, in eleven lines
The whole implementation is in libraries/adb-scrcpy/src/client.ts, and it is eleven lines replacing six. Before the change, AdbScrcpyClient.start() launched the server with a plain argv array:
if (options.spawner) { process = await options.spawner(args); } else { process = await adb.subprocess.noneProtocol.spawn(args); }
After the change (d658978d0394049478b2a1d94af4fa2578674c7f, 2026-10-26T14:52:18Z):
1 2 3 4 5 6 7 8 9 10 11 12 13 14
if (options.spawner) { process = await options.spawner(args); } else { // ClipboardService checks that the caller owns com.android.shell (UID 2000). const command = escapeArg( [ `CLASSPATH=${escapeArg(path)}`, ...args.slice(1).map(escapeArg), ].join(" "), ); process = await adb.subprocess.noneProtocol.spawn( `if [ "$(id -u)" = 0 ]; then exec su 2000 sh -c ${command}; else exec sh -c ${command}; fi`, ); }
Four properties of this diff are worth pulling apart, because each one is a design decision rather than an implementation detail.
The UID probe happens on the device, inside the command. The obvious alternative is to ask the host what UID adbd is running as — one extra adb shell id -u round trip before the server launches — and then branch in JavaScript. The author explicitly rejected that, and the test suite asserts it:
1 2 3 4 5
assert.equal( device.spawn.mock.callCount(), 1, "UID selection must not add an ADB round trip", );
The reasoning is sound. adb shell id -u is a full ADB command execution: a none: protocol request, a subprocess spawn on the device, a round trip over WebUSB, and a shell: framing. Adding it to every scrcpy session startup — including on the overwhelmingly common non-rooted path where it would tell you something you already know — is a real latency cost for zero information. Evaluating $(id -u) inside the device-side command string costs one fork/exec of id on a device that is about to run app_process anyway. The information is gathered exactly where it is needed, at the point of use, for free.
The su switch is explicit, and the failure mode is fail-closed. The command is:
1
if [ "$(id -u)" = 0 ]; thenexec su 2000 sh -c '<command>'; elseexec sh -c '<command>'; fi
exec replaces the current shell rather than forking, so there is no extra process left to reap and the exit status of the server propagates directly. And if su 2000 fails — a ROM without su, an emulator image where su requires a different invocation, a su that refuses the UID — the error propagates out as a startup failure. It does not fall back to running as root. The test asserts this explicitly:
1 2 3 4 5 6 7 8 9
await assert.rejects(device.start(), (error) => { assert.equal((error as { status?: number }).status, 23); returntrue; }); assert.equal( device.spawn.mock.callCount(), 1, "A failed UID switch must not retry as root", );
Falling back to root on a failed su would be the wrong default. Root is precisely the identity that triggers the clipboard failure in the first place; a silent fallback would turn a clean, diagnosable startup error into a session that starts and then mysteriously loses its control stream.
Custom spawners are left completely alone. If the caller supplies options.spawner, it receives the originalargs array, unmodified — no UID probe, no quoting, no wrapping. The test asserts both halves of that: the custom spawner is called with the plain argv array, and the underlying adb.subprocess.noneProtocol.spawn is never called at all (device.spawn.mock.callCount() === 0). This matters because a custom spawner is exactly the escape hatch a user reaches for when the default launcher does not fit their device — a rooted setup that wants the server to stay root, a Magisk-specific su invocation, a device with a non-standard app_process path. The default path changes; the escape hatch does not.
su 2000, not su shell. The second argument to su is the numeric UID. com.android.shell is UID 2000 on every Android version that matters here, and passing the number avoids depending on a username that may or may not not resolve on a given ROM.
The second commit exists because the first one was wrong
The PR has two commits, and the gap between them is the most instructive part of the whole thing:
1 2
d658978d039 2026-10-26T14:52:18Z fix(adb-scrcpy): launch scrcpy as shell under root adbd f6cf7fe9dc 2026-10-26T15:22:47Z fix(adb-scrcpy): escape inner shell arguments
The second commit was authored thirty minutes after the first, in response to a CodeRabbit review comment. And CodeRabbit was right.
Here is the shape of the first commit’s command construction. The inner command is built by joining escaped arguments, and then the result is escaped again for the outer sh -c:
Look at the first element: CLASSPATH=${escapeArg(path)}. The escapeArg call wraps path in single quotes, and then the whole CLASSPATH=... string is escaped again as part of the outer command. The outer escape turns the inner single quotes into literal characters inside a single-quoted string. The result is that the device shell sees a CLASSPATH whose value contains literal quote characters rather than the path itself.
The CodeRabbit review summary names the concern precisely:
CodeRabbit caught a subtle shell-injection risk: escape each inner server argument before quoting the outer command, while preserving CLASSPATH= semantics.
The fix in f6cf7fe9dc was to escape the value inside the CLASSPATH= assignment, and each remaining argument, and then quote the joined inner command — the shape shown above. In other words, the first commit had the quoting order backwards for one of the seven elements, and the second commit moved it.
This is not a nitpick. CLASSPATH= is an environment-variable assignment, not a command word, and it has to be handled by a different rule than the arguments that follow it: CLASSPATH=/path sets a variable, but CLASSPATH='/path' sets a variable whose value includes the quote characters. When you are building a nested-quoting command, the environment-assignment position and the argument position need different treatment, and getting them backwards produces a value that is subtly wrong rather than loudly broken.
The author also added real-shell regression tests rather than mock-level assertions. The test fixture writes actual id, su, and app_process executables to a temp directory and puts that directory first on PATH:
The su stub records the UID it was asked to switch to and then execs the rest of its arguments, so the test can assert that the server really was launched as UID 2000 and that the CLASSPATH the server sees is the path, not a quoted path. The spawn mock runs the command through a real sh -c with that PATH:
A path containing a space, single quotes, double quotes, a command substitution that would touch a marker file, and a semicolon. The test asserts the marker file was not created:
That last assertion is the interesting one: the fake app_process prints "$CLASSPATH" and "$@" one per line, so the test is checking the exact bytes that arrive at the server process. If the quoting is wrong in any way — an extra quote character, a lost space, a $(...) that got expanded — the split lines will not match and the test fails.
The PR description states the coverage honestly:
The two new quoting cases fail before the fix and pass afterward; embedded commands do not execute.
“Fail before the fix” is the load-bearing phrase. The author ran the new tests against the first commit and watched them fail, which is how the injection was discovered rather than merely asserted away.
Why this matters more than a clipboard bug
Two observations, one about the library and one about the ecosystem.
First, this is the general shape of browser-based ADB. Everything a browser ADB client does on the device side eventually becomes a string executed by a shell it does not control. ya-webadb’s none: and shell: protocols both end up as sh -c ... on the Android side — they differ only in how stdin/stdout are framed on the wire, not in their execution model. The library’s own escapeArg() helper (libraries/adb/src/service/subprocess/utils.ts) has been the answer to this since PR #786 landed in August 2025:
exportfunctionescapeArg(s: string) { let result = ""; result += `'`;
let base = 0; while (true) { const found = s.indexOf(`'`, base); if (found === -1) { result += s.substring(base); break; } result += s.substring(base, found); // a'b becomes 'a'\''b', which is 'a' + \' + 'b' // (quoted string 'a', escaped single quote, and quoted string 'b') result += String.raw`'\''`; base = found + 1; }
result += `'`; return result; }
Single-quote wrapping, with embedded single quotes emitted as '\''. That is correct POSIX single-quote escaping and it is inert against every metacharacter — $, backtick, ;, |, &, (, ), <, >, newline. What the PR’s second commit demonstrates is that correctly applying it is harder than it looks, because the command being built is nested: the inner command’s arguments must be escaped once for the inner sh -c, and then the joined inner command must be escaped again for the outer sh -c. Two layers, two different rules, and the environment-assignment slot at the front does not follow the same rule as the arguments.
Second, the fix is not yet merged. As of 2026-10-27T01:00Z, PR yume-chan/ya-webadb#875 is open, mergeable_state: clean, head f6cf7fe9dcc13bdd54805ab66eb655e9690b6fb7, base main, with a patch changeset queued for @yume-chan/adb-scrcpy. The main branch has not moved since 7bcec613 (2026-10-09T08:02:12Z, the v3.0.0-beta.4 release), so this is not in any published version yet. The device-side validation in the PR description is on an Android 10 emulator with root adbd and scrcpy server 3.3.3, through a local HTTPS/HTTP2 Preview in Chrome 155 — consecutive Ctrl+V pastes of ASCII, Chinese/emoji, and a URL matched the native text field exactly, with the control stream and video stream both staying alive and ps confirming scrcpy was UID 2000 while adbd stayed root. The author is explicit about the limits:
This has not been device-tested on other Android versions or su implementations.
That caveat matters more than usual for a fix whose central mechanism is su, because su is the least standardised program in the Android ecosystem. Magisk’s su, the emulator image’s su, SuperSU, and a vendor-blessed root solution all accept different argument shapes and enforce different policies. su 2000 is the most conventional form of the invocation and the most likely to work, but “most likely” is doing real work in that sentence.
What this means for webadb.online
No code change to webadb, and no change to the webadb screencast panel.
webadb’s screencast implementation does not use @yume-chan/adb-scrcpy at all. It talks to adb shell screenrecord and polls the resulting file (lib/screencast/), because screenrecord is a different mechanism with a different set of OEM problems — the panel has no scrcpy server to launch as root, no ClipboardService to talk to, and no su to negotiate. The relevant dependency set in webadb’s package.json is @yume-chan/adb, @yume-chan/adb-credential-web, @yume-chan/adb-daemon-webusb, and @yume-chan/stream-extra — the adb-scrcpy package is not among them, and grep -rn "AdbScrcpy" lib/ components/ returns nothing.
Two things are worth keeping, though.
The first is that this PR is a live demonstration of the argument-escaping problem that webadb’s own device-side command layer has to solve. webadb runs arbitrary shell commands on the device — pm list packages -f, dumpsys package, am start -n, stat, tail -c +N for the screencast poller — and the paths it operates on (a package name, a filename chosen by the user in the File Manager) are not guaranteed to be free of spaces or shell metacharacters. When PR #875 merges, the right response is not to copy the UID-switch logic but to re-read the escaping discipline it implements, and to check whether our own command construction quotes its arguments at all. A browser ADB client is exactly the context where a $(...) in a filename becomes a shell injection on the user’s own device — low severity, but the class of bug is worth auditing for rather than inheriting by accident.
The second is the workflow lesson, which is more general than this bug. A two-year-old upstream issue with no maintainer fix was closed in a browser-based library in the same afternoon it was properly reported, with a minimal patch, a real-device validation, and a regression suite that specifically tries to break the fix. The upstream issue Genymobile/scrcpy#4614 remains open. Nothing about the fix required anything that was not already available to a scrcpy maintainer — the constraint was that scrcpy’s own launch path is a native binary with a different set of options, while the library’s launcher is a string it fully controls, which makes “check the UID and re-exec” a two-line device-side change rather than a native-binary feature.
That is the shape of the webadb project in general: the browser gives you control over the launch command in a way that a native client never does, and that control is worth more than it looks when something on the device needs to be run as a specific user.
A single upstream commit landed in yume-chan/ya-webadb yesterday — 3a7d781cf3673e307ad514422c776505e14ffcac, feat(scrcpy): add inject key shorthands — that adds three methods to the scrcpy control writer: injectKeyDown, injectKeyUp, and injectKeyPress. Twenty-nine lines added, one line changed, one file touched. The whole feature is the difference between a caller having to know the scrcpy wire protocol’s action byte and not having to.
The interesting part is not the size of the diff. It is that this is the first time the scrcpy control surface in ya-webadb has offered a semantically complete key press — and that it arrived as a three-method shorthand rather than as a new message type, which means it works against every scrcpy server version the library supports, including the 1.15 servers that predate the modern control-message framing.
The commit
The whole change is in libraries/scrcpy/src/control/writer.ts, which is the class that owns the outgoing control socket. Before the commit, the class exposed exactly one key-related method:
exportconstScrcpyInjectKeyCodeControlMessage = struct( { // value of `type` can change between versions type: u8, action: u8<AndroidKeyEventAction>(), keyCode: u32<AndroidKeyCode>(), repeat: u32, metaState: u32<AndroidKeyEventMeta>(), }, { littleEndian: false }, );
Five fields, big-endian, 14 bytes on the wire. The action field is typed as u8<AndroidKeyEventAction>(), and the enum it draws from is in libraries/scrcpy/src/android/key-event.ts:
Two values. Down is 0, Up is 1. This mirrors Android’s own KeyEvent.ACTION_DOWN / KeyEvent.ACTION_UP, which are 0 and 1 in the platform’s KeyEvent.java — the same file the enum’s sibling AndroidKeyEventMeta cites in its comment header.
So before yesterday, the caller had to write all of this:
…to produce a single Down event, and then a second nearly-identical call with action: AndroidKeyEventAction.Up to complete the press. Two calls, two chances to forget the repeat or metaState fields, and — the real hazard — a Down with no matching Up if the second call fails or the socket dies between them.
What landed
The commit adds three methods, and the diff is instructive in how small it is:
The Omit<..., "type" | "action"> signature. The caller supplies keyCode, repeat, and metaState; the library supplies type (because the numeric type byte is version-dependent — see the // value of type can change between versions comment in the struct) and action (because that’s exactly what the shorthand exists to pick). The Omit is doing real type-safety work: it is structurally impossible to pass the wrong action to injectKeyDown, and impossible to forget type entirely. Compare with the pre-commit signature, which required the caller to spell out action correctly by hand and offered no way to express “down” as intent.
The type → action import flip. The diff changes import type { AndroidKeyEventAction } to a value import, because the shorthands need the enum’s runtime values, not just its type. This is a one-line change with a real consequence for bundlers: AndroidKeyEventAction is a const object literal with two numeric members, so it is tree-shakeable and cheap. But it does mean the control/writer.ts module now has a runtime dependency on ../android/index.js, where before the enum was type-only and got erased at compile time. The AndroidKeyEventAction import in control/serializer.ts is still import type, because the serializer only ever compares against the enum, never reads a member off it.
The hold default of 100ms.injectKeyPress awaits delay(hold) between the two events, defaulting to 100 milliseconds. The delay helper comes from @yume-chan/async (a runtime dependency of @yume-chan/scrcpy at ^4.1.3), and it is the smallest possible implementation:
A plain setTimeout wrapped in a promise. No timer pooling, no cancellation token, no abort signal. The hold parameter is a wall-clock delay between the Down and the Up — it does not depend on the device acknowledging anything, because the scrcpy control protocol has no acknowledgement for key events. The control socket is a fire-and-forget stream of control messages.
100ms is a defensible default: it is long enough that a human-perceptible key press registers as a press rather than a flicker, and short enough that it doesn’t feel laggy. But it is a guess — scrcpy’s own Java client uses KeyEvent repeat semantics rather than a fixed hold, and the real duration that matters depends on the input method editor on the device side. For a text field with an IME, a Down/Up pair 100ms apart is a normal key press; for a game with a virtual joystick, the hold duration is the input.
Why this is not a new message type
The most important property of the change is what it isn’t. It isn’t a new control message, a new struct, or a new wire format. It’s three methods on top of injectKeyCode, which has existed since the library supported scrcpy server 1.15.
That matters because of how the scrcpy control protocol is versioned. Each supported server version gets its own implementation tree — libraries/scrcpy/src/1_15/, 1_18/, 2_4/, 2_7/, 3_0/, 4_0/, 4_1/ — and each defines its own control-message-types.ts mapping from the symbolic ScrcpyControlMessageType to a numeric byte. InjectKeyCode is type 0 in every single one of them:
The keyCode field itself is a u32 in every version, and the Android key code numbering it carries has been stable since API 1. So an injectKeyPress written against the modern writer works against a scrcpy 1.15 server with no version-specific branch at all. The shorthand inherits the full version range of the primitive it wraps.
This is a genuinely different design decision from PR #875, which landed the day before (covered in yesterday’s post) — that one added device-side behaviour keyed off the server version. This one adds zero protocol surface.
The version quirk that makes action load-bearing
There is one place in the codebase where the action byte means something subtly different, and it explains why the shorthand matters more than it first appears.
In libraries/scrcpy/src/1_15/impl/back-or-screen-on.ts, the BackOrScreenOn control message for scrcpy server 1.15 is serialized conditionally on the action:
For the 1.15 protocol, BackOrScreenOn is an EmptyControlMessage — it carries no payload — and the server interprets the arrival of the message as the action. Sending an Up would be meaningless, so the serializer drops it. Compare with the 1.18 implementation, where BackOrScreenOnControlMessage is a real struct with an action: u8 field:
The key-event action byte is thus not a uniform, always-present field across the protocol’s history. In InjectKeyCode it always is — but that consistency is a property of that particular message, established over seven server versions, not a property of the protocol.
The new shorthands sit above the one message where the action byte is reliably present. They do not extend to backOrScreenOn, which still requires the caller to pass an explicit AndroidKeyEventAction — a deliberate asymmetry, since the 1.15 special case means a Down/Up pair is not universally meaningful.
A caveat about injectKeyPress and the control socket
injectKeyPress is the first method on ScrcpyControlMessageWriter to be async in a way that spans more than one write. Every other write method (injectTouch, injectText, injectKeyCode, setClipboard, expandNotificationPanel, rotateDevice, startApp, resizeDisplay) is either synchronous or awaits exactly one write().
injectKeyPress awaits two writes separated by a timer. That creates a real hazard for callers that don’t think about it: the Down can be written, the timer can elapse, and the Up write can fail — because the socket closed, because the server went away, because the session was torn down during the 100ms window. In that case the device has received a key-down with no key-up.
On Android that leaves the key in a pressed state. Depending on the app, that means a modifier key stuck on, a held-down back button, or a text field that keeps repeating the character. There is no rollback, because the scrcpy control protocol has no “release everything” message; the closest thing is setDisplayPower, which is unrelated.
The fix at the call site is straightforward — check the result of the Up:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
try { await controller.write.injectKeyPress({ keyCode: AndroidKeyCode.Enter, repeat: 0, metaState: AndroidKeyEventMeta.None, }); } catch { // The Down may or may not have landed. Best-effort cleanup: await controller.write.injectKeyUp({ keyCode: AndroidKeyCode.Enter, repeat: 0, metaState: AndroidKeyEventMeta.None, }).catch(() => {}); }
But it’s worth knowing that the API’s failure mode is “possibly stuck key” rather than “nothing happened”, and that the library does not paper over it.
Where this is not yet available
The commit is on main only. It is not in any published tag:
1 2
$ git tag --contains 3a7d781c (no output)
The most recent release is still v3.0.0-beta.4 (7bcec613, 2026-10-09), and the diff between that tag and main is exactly this one commit:
That is the entire delta between the last release and upstream HEAD as of 2026-10-28T01:00Z. Whatever comes next — beta.5, or a straight 3.0.0 — will carry these three methods.
Also worth noting: this was a direct push to main, not a pull request. The commit is authored by Simon Chan ([email protected]), the same maintainer who authored PR #875 the day before, but unlike #875 it has no review thread, no issue link, and no CI-linked test in the PR sense. The commit message is a single conventional-commit line.
That is not a criticism — a 29-line, purely additive API convenience with no protocol change is exactly the kind of change that does not need a review cycle. But it does mean there is no discussion trail documenting whyhold defaults to 100ms, or whether injectKeyPress was meant to handle the Up failure case. Those are open questions a PR would have answered.
What this means for webadb.online
No impact on webadb this week. The webadb screencast panel does not use @yume-chan/scrcpy or @yume-chan/adb-scrcpy — it talks to adb shell screenrecord and polls the output file, because screenrecord has a different set of OEM problems than the scrcpy server does. The dependency set in package.json is @yume-chan/adb, @yume-chan/adb-credential-web, @yume-chan/adb-daemon-webusb, and @yume-chan/stream-extra; the scrcpy packages are not among them, and grep -rn "AdbScrcpy" lib/ components/ returns nothing.
There is, however, one idea worth carrying forward. webadb’s own shell panel injects input through a completely different path — adb shell input keyevent <code> — which is a process spawn per key event, with all the cost that implies: a new shell process, a round trip, and a ~100ms+ latency floor. The scrcpy path is a 14-byte binary write on a socket that is already open. If webadb ever wants responsive hardware-key handling (volume keys, power, media keys), the scrcpy control channel is the architecture that scales, and injectKeyPress is the API shape it would use.
The hold default of 100ms is also a reasonable reference point for whatever we do build. The adb shell input keyevent equivalent has no notion of hold duration at all — it synthesizes a down+up pair server-side with a fixed, undocumented interval. Having a client-controllable hold parameter is the kind of detail that turns out to matter when you are driving an IME.
On 2026-10-28 at 00:09 UTC, colinaaa closed their own pull request against yume-chan/ya-webadb — PR #875, fix(adb-scrcpy): launch scrcpy as shell under root adbd — without merging it, and closed the underlying issue #874 in the same breath. The reason, given in both closing comments, is that the bug was fixed upstream in scrcpy 4.0 and never needed a library-side workaround at all. This reverses yesterday’s post on this blog, which described #875 as a fix landing in ya-webadb. It did not land. The library’s main branch has not changed since 3a7d781c and still contains no UID-switching code.
What did land, in the upstream project, is a sixteen-line method in the scrcpy server that drops root privileges at startup. The interesting part is not the method — it is the long arc that produced it, and the fact that the library-level fix was rejected for a reason that has nothing to do with code quality.
The upstream fix: Os.setuid(2000) in Server.java
The scrcpy commit is 42632d3f53657238ca653b6f39d4226e9f0a1971, titled “Drop root privileges on startup”, authored 2025-12-29 and committed the following day. It touches exactly one file, server/src/main/java/com/genymobile/scrcpy/Server.java, +16 lines, no deletions:
1 2 3 4 5 6 7 8 9 10 11 12
@SuppressWarnings("deprecation") privatestaticvoiddropRootPrivileges() { try { if (Os.getuid() == 0) { // Copy-paste does not work with root user // <https://github.com/Genymobile/scrcpy/issues/6224> Os.setuid(2000); } } catch (Exception e) { Ln.w("Cannot set UID", e); } }
and the call site sits in internalMain, immediately before the main looper is prepared and the options are parsed:
1 2 3 4 5
dropRootPrivileges();
prepareMainLooper();
Optionsoptions= Options.parse(args);
That placement matters. The UID drop happens beforeOptions.parse(args), before the encoder is created, before any control socket is opened — so by the time scrcpy touches ClipboardService, its calling UID is already 2000 and the identity check that was failing now passes.
The @SuppressWarnings("deprecation") is not decoration. Os.setuid(int) was deprecated in API level 29 with the note “Android Applications do not have sufficient privileges to call this method.” The deprecation is aimed at ordinary apps trying to change their own identity; the scrcpy server is running as root at that moment, which is precisely the case where the call does work, so upstream suppresses the warning rather than shipping a deprecated call that fails to compile under -Werror. The catch block is what makes the suppression honest: a ROM where the setuid is refused logs "Cannot set UID" and the server continues rather than dying. Note that the Os.getuid() == 0 guard means the call is never attempted on a device where adbd was not root to begin with — the whole method is a no-op there, and the catch arm exists for the genuinely awkward case: a device where adbd is root but the setuid(2000) is nevertheless refused.
The commit message is terse in the way that upstream scrcpy commits usually are, and it credits the suggestion to the person who drove the whole thing:
1 2 3 4 5 6 7 8 9 10
Drop root privileges on startup
On rooted devices, switch to user 2000 during startup.
Copy-paste does not work as root (user 0), so switching to 2000 fixes the issue.
Simon Chan is yume-chan — the ya-webadb maintainer, the author of the scrcpy.js-adjacent TypeScript port, and the person who spent the five months between filing the analysis and landing the fix reverse-engineering Samsung’s clipboard stack to get this resolved upstream. “Suggested-by” in a scrcpy commit is the project maintainer’s way of crediting an external contributor for an idea that landed in upstream code; the commit itself was authored and committed by Romain Vimont (rom1v).
It shipped in scrcpy v4.0, released 2026-05-12, whose changelog line reads exactly:
1
- Fix copy-paste on rooted device (#6224)
The bug, and the arc that produced the fix
The failure is a UID/identity mismatch in ClipboardService, and it has been reported upstream since January 2024 as scrcpy issue #4614, “Unable to copy / paste between device and computer”. The reporter was running a rooted Android 9 device, and the stack trace bottoms out in the Android framework rather than in scrcpy:
1 2 3 4 5 6 7 8 9
Caused by: java.lang.SecurityException: Calling uid 0 does not own package com.android.shell at android.os.Parcel.readException(Parcel.java:1943) at android.os.Parcel.readException(Parcel.java:1889) at android.content.IClipboard$Stub$Proxy.getPrimaryClip(IClipboard.java:187) ... 7 more Caused by: android.os.RemoteException: Remote stack trace: at com.android.server.clipboard.ClipboardService.addActiveOwnerLocked(Unknown Source:57) at com.android.server.clipboard.ClipboardService.access$600(Unknown Source:0) at com.android.server.clipboard.ClipboardService$ClipboardImpl.getPrimaryClip(Unknown Source:30)
Read that carefully: ClipboardService requires that the calling UID owns the package identity com.android.shell. com.android.shell is UID 2000 on every Android version that matters. When adbd runs as root — which is what adb root does, and what a rooted emulator or a Magisk-granted root adbd gives you — the scrcpy server inherits UID 0, and the framework refuses the call. The copy-paste breaks, and on scrcpy 3.3.1 the control-recv thread exits, taking the control stream and the screen stream down with it.
yume-chan stated the root cause in September 2025, in one line:
Google forgot to allow root to access the clipboard without an associated app. So Scrcpy must run as system (uid 1000) or shell (uid 2000) (ref #1606 and #4127)
There is no framework permission to grant. Root is UID 0, and UID 0 owns no package. The only correct answer is to not be root by the time the clipboard is touched.
Why it took so long: Samsung’s SEM clipboard
The reason this was hard is that scrcpy’s clipboard access on Samsung devices does not go through a plain Context.getSystemService(CLIPBOARD_SERVICE). Samsung’s One UI layers a vendor clipboard service on top, and the call chain reaches into a package that scrcpy has no identity for. From scrcpy issue #6224 — the issue that the merged commit closes — the trace on a Samsung S10 / S9 running Android 11 goes somewhere completely unexpected:
1 2 3 4 5 6
at com.samsung.android.emergencymode.SemEmergencyManager.getModeType(SemEmergencyManager.java:343) at com.samsung.android.content.clipboard.SemClipboardManager.isUPSMode(SemClipboardManager.java:1641) at com.samsung.android.content.clipboard.SemClipboardManager.isEnabled(SemClipboardManager.java:1039) at android.content.ClipboardManager.setPrimaryClip(ClipboardManager.java:132) at com.genymobile.scrcpy.wrappers.ClipboardManager.setText(ClipboardManager.java:36) at com.genymobile.scrcpy.device.Device.setClipboardText(Device.java:128)
Samsung’s SemEmergencyManager — emergency mode, UPS mode — is consulted insidesetPrimaryClip, and it calls context.createPackageContext("android", 2) to get a context whose package name is android. That derived context is not scrcpy’s FakeContext, so the UID ownership check inside ClipboardService.addActiveOwnerLocked sees the android package and the caller’s UID 2000 and throws “Given calling package android does not match caller’s uid 2000”. The same report shows the regression introduced by scrcpy commit 8a02e3c2f58cffc3fdd8c08b26aae04bbf9d5a97, which moved clipboard access onto the SDK ClipboardManager (previously scrcpy 3.2 worked fine on the same devices).
yume-chan proposed a series of progressively deeper patches to scrcpy’s FakeContext — first patching the mContext field of the ClipboardManager service, then noticing that SemEmergencyManager‘s createPackageContext call bypasses FakeContext entirely, then trying the radical version where the patching happens on Workarounds.getSystemContext() rather than on a ContextWrapper:
Most things work, but getContentResolver can’t be patched (especially for derived contexts created by createXXXContext()), causing java.lang.SecurityException: Unable to find app for caller ... when getting content provider settings
Every one of those approaches is a fight with a vendor framework’s private field layout. In December 2025, yume-chan proposed the boring solution instead — drop privileges — and rom1v (the scrcpy maintainer) replied “Yes, that might be a solution :+1:”, tested 81b90bf58bd4f0a297f9c50ab7b35b5fdcbc9c98 with a prebuilt server binary, and merged 42632d3f a day later.
The chronology matters for understanding what the library-side patch was actually up against. By the time PR #875 was opened on 2026-10-26, the upstream fix had been in scrcpy for ten months, and the maintainer of ya-webadb knew it — his review comment on 2026-10-27 pointed at it directly:
The withdrawn PR was not rejected for being wrong. It was rejected for being unnecessary and for changing a default that some users depend on.
The proposal was to rewrite the default launcher in libraries/adb-scrcpy/src/client.ts, replacing the plain argv spawn with a device-side shell snippet:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
if (options.spawner) { process = await options.spawner(args); } else { // ClipboardService checks that the caller owns com.android.shell (UID 2000). const command = escapeArg( [ `CLASSPATH=${escapeArg(path)}`, ...args.slice(1).map(escapeArg), ].join(" "), ); process = await adb.subprocess.noneProtocol.spawn( `if [ "$(id -u)" = 0 ]; then exec su 2000 sh -c ${command}; else exec sh -c ${command}; fi`, ); }
That is a well-built patch. The escapeArg nesting is the interesting part: the classpath and each server argument are escaped once for the inner sh -c, and the resulting command string is escaped again for the outer shell — a double-layer quoting scheme that the PR’s own regression tests exercise against paths and options containing spaces, quotes, command substitution, and semicolons, asserting that the embedded commands do not execute. CodeRabbit flagged the first version of this as an escaping bug and the author fixed it; the bot approved the second. There was also a 209-line test file with a fixture that fakes id, su, and app_process on PATH to assert the UID-switch behaviour without a device.
None of that is why it was dropped. The maintainer’s objection was about semantics, in two parts.
First, su compatibility cannot be assumed. The comment:
su is not a standard Android binary. On a Magisk-patched device it is Magisk’s su; on a KernelSU device it is KernelSU’s; on a rooted emulator image it may be a BusyBox su with different argument handling; on some OEM-locked builds there is no su at all even though adbd runs as root. su 2000 — passing a numeric UID rather than the username shell — is also not universally supported: toybox su and Magisk’s su accept it, several older su implementations accept only names. A default path that depends on su behaving a specific way is a default path that can break on devices the library currently supports.
Second, running as root is a feature for some users. The maintainer’s issue-level comment:
If the su command works on all root devices, I will accept this workaround. However, some people might actually want to run Scrcpy as root/system to get secure displays on Android 12+, so this should be an opt-in feature.
That is the crux, and it is a real platform constraint rather than a preference. FLAG_SECURE surfaces — the ones that exclude their content from screenshots and screen recording — can only be created by a process running as AID_GRAPHICS (UID 1000) or AID_SYSTEM. Since Android 12, createSecureDisplay from a shell-UID process is refused outright; the upstream scrcpy v1.18 release notes recorded the restriction at the time:
Since Android 12, creating a secure display with shell permissions is not permitted anymore (#2129). As a consequence, protected content may not be mirrored on Android 12.
The workaround scrcpy shipped at the time was to not create a secure display — mirroring everything, including protected content — which is a security regression the user may not want. Users who deliberately run adb root before launching scrcpy do it to get those secure displays, and the alternative of launching as UID 1000 is exactly what the upstream scrcpy PR #4127 (“Introduce --root option for (rooted) secure display support on Android 12+”) proposes as an opt-in flag. A library default that silently drops privileges would remove a capability that currently works, in exchange for fixing a clipboard bug that the upstream server already fixed on its own.
The resolution: the escape hatch that was already there. The maintainer pointed at it in an inline review comment:
options.spawner is the escape hatch for these situations (I used this to launch Scrcpy as uid 1000 on Android 10).
AdbNoneProtocolSpawner is (command: string | readonly string[], signal?: AbortSignal) => Promise<AdbNoneProtocolProcess> (libraries/adb/src/service/subprocess/none/spawner.ts), and AdbScrcpyClient.start checks it before falling back to the default:
1 2 3 4 5
if (options.spawner) { process = await options.spawner(args); } else { process = await adb.subprocess.noneProtocol.spawn(args); }
A caller who needs UID-specific behaviour supplies a spawner; the library does not guess on their behalf. The closing comments confirm that is exactly what the PR author did:
Thanks @yume-chan for the thoughtful feedback and for explaining the root/system use cases. I agree that changing the default UID could break callers that need those privileges, and that su compatibility should not be assumed across rooted devices. The existing options.spawner is a good fit for our environment-specific workaround; we’ve moved it into our application and verified it on our affected Android 10 setup.
So the workaround still exists — it just moved from the library’s default launcher into the application that needed it, which is the correct place for an environment-specific hack. Nothing ships in ya-webadb. The library’s main branch still has exactly one commit since 2026-10-09, and the AdbScrcpyClient default launcher is still the two-line spawn(args) above.
What actually ships: the fix is in the server, not the library
The important structural point for anyone consuming ya-webadb is that this class of bug is fixed on the device side, in the scrcpy server binary, and ya-webadb already ships the options classes that select the fixed server.
ya-webadb’s @yume-chan/scrcpy package has a directory per server version, and libraries/adb-scrcpy/src/ mirrors it:
Server version
Options class
File
options.version default
3.3.3
AdbScrcpyOptions3_3_3
libraries/adb-scrcpy/src/3_3_3.ts
"3.3.3"
3.3.4
AdbScrcpyOptions3_3_4
libraries/adb-scrcpy/src/3_3_4.ts
"3.3.4"
4.0
AdbScrcpyOptions4_0
libraries/adb-scrcpy/src/4_0.ts
"4.0"
4.1
AdbScrcpyOptions4_1
libraries/adb-scrcpy/src/4_1.ts
"4.1"
5.0
AdbScrcpyOptions5_0
libraries/adb-scrcpy/src/5_0.ts
"5.0"
5.0.1
AdbScrcpyOptions5_0_1
libraries/adb-scrcpy/src/5_0_1.ts
"5.0.1"
Each one carries the server version string it will send to app_process:
The version string is how the client tells the server which build it is talking to, and the scrcpy server binary is what contains dropRootPrivileges(). So the version selection is the fix selection: AdbScrcpyOptions4_0 and later transparently get the UID drop, because the server binary does it, not the TypeScript. AdbScrcpyOptions3_3_3 — the version used in the original bug report — does not, because that server predates the fix.
This is worth stating plainly because the natural assumption runs the other way. A reader might expect the clipboard fix to be a JavaScript-side concern, something the library could toggle. It is not. The library cannot drop the server’s UID after the fact; it can only choose which server binary to run. The browser-side code has no Os.setuid to call.
The version string is a contract, not a label
There is a subtlety worth stating: the server version is a string in the options object, and the library does not validate it against the server binary that was actually pushed to the device.
AdbScrcpyClient.pushServer writes whatever bytes it is handed to DefaultServerPath:
So a mismatch between the pushed binary and the declared version is possible in principle, but it is caught immediately and loudly: the server compares the client-supplied version string against its own compiled-in VERSION_NAME and throws on the first line of Options.parse:
StringclientVersion= args[0]; if (!clientVersion.equals(BuildConfig.VERSION_NAME)) { thrownewIllegalArgumentException( "The server version (" + BuildConfig.VERSION_NAME + ") does not match the client " + "(" + clientVersion + ")"); }
The failure surfaces in the library as an AdbScrcpyExitedError carrying the server’s log lines, because Options.parse runs inside the spawned process and the thrown exception kills it before the streams are consumed:
The practical consequence is that the declared version string is a load-bearing part of the protocol contract, not a cosmetic label. The @yume-chan/fetch-scrcpy-server package exists to make keeping the two in sync a non-issue by generating a version-pinned module per server release, so that options.version and the pushed binary cannot drift apart by accident.
What this means for webadb.online
Direct impact on webadb.online today: none, and that is by design rather than by luck.
webadb.online’s screencast panel does not use AdbScrcpyClient. It implements its own capture path in lib/screencast/pipeline.ts — a file-based polling pipeline built on adb shell screenrecord writing to /sdcard/webadb-stream.mp4, with the device’s own ftyp/moov used as the init segment and an incremental readSince(offset) tail, because stock AOSP screenrecord has no stdout mode and the OEM ROMs we target strip the Pixel-only --output-format=h264 extension entirely. That pipeline never spawns the scrcpy server, so the root-UID clipboard bug cannot reach it: it has no clipboard bridge at all. It mirrors pixels and injects touch and key events; it does not read or write the device clipboard.
The clipboard panel in webadb.online is a separate mechanism again — it uses adb shell cmd clipboard and navigator.clipboard, not the scrcpy control socket. On the OEM-restricted ROMs we care about, cmd clipboard is often stripped entirely, and the panel’s job is to say so plainly rather than pretend. The scrcpy control socket’s clipboard path — the one that crashed on rooted devices — is a third mechanism that webadb.online does not expose.
What this episode is useful for, beyond the upstream history, is as a worked example of the architecture we are deliberately not adopting. The library-side fix was rejected not because it was badly written — it was well written, with device-free regression tests and a fixture that fakes id/su/app_process — but because it guessed at device state that only the caller knows. The correct shape turned out to be: fix it where the privilege is held (the server binary), keep the escape hatch (the custom spawner) for the cases where the caller needs to override, and do not change a default that some users depend on.
If webadb.online ever grows a scrcpy-server-based panel — which would be the natural way to get a real control channel and clipboard bridging that the screenrecord pipeline cannot provide — the rules for that panel are now written: use AdbScrcpyOptions4_1 or later so the server drops root privileges on its own, and expose a spawner override for users who deliberately want root or system UID for secure displays on Android 12+.
One closing note on the release cadence, since it affects when this fix reaches people: the upstream commit landed 2025-12-29 and shipped in scrcpy v4.0 on 2026-05-12 — a four-and-a-half month gap between merge and release. The library-side workaround that existed in parallel for those four months was solving a problem that had already been solved upstream, in a binary that users would only pick up once 4.0 was published. That is the kind of lag worth remembering before opening a library-side PR for something that a dependency might already have fixed.
Chrome’s Source-Specific Multicast feature for Direct Sockets picked up its third LGTM on 2026-10-21, eight days after it was already shipping enabled by default in Chrome 158 — an unusual ordering that says something about how the Chromium feature pipeline handles a feature that gets implemented before its intent process finishes. Meanwhile an open pull request against the WICG Direct Sockets specification documents a dozen concrete places where the shipped Chromium implementation and the spec text disagree, most of them about which DOMException a caller actually gets back.
This post covers both: the intent thread’s approval path, and the spec-vs- implementation divergence that is still open and unmerged as of this writing. It also has a note on what this means for a browser-based ADB client trying to use multicast for device discovery, which is the reason webadb.online pays attention to this API at all.
The reply sequence is short and worth reading in full, because it is an example of the Chromium review process being asked a question it is not usually asked, and answering it.
2026-10-19, 13:57 PDT — Reilly Grant (Chrome, IWA owner):
IWA OWNER LGTM
This is an extension of an extension of an existing IWA-only API. The previous justification for why multicast would create a privacy issues for the web platform (see also threads on net-dev@) still applies.
2026-10-19, 15:48 PDT — Alex Russell (Chrome, API owner for the broader web platform):
What’s the developer benefit for SSM? Is anyone asking for this?
That question is the interesting one. Blink intents require three LGTMs from API owners before implementation can proceed, and a feature that is already in the tree and already enabled by default does not obviously need to survive that process — the implementation is a fact by then, and the intent is formality. Asking “is anyone asking for this” puts the burden back on the requester, which is the reviewer’s job.
The benefit is that SSM makes the multicast API usable beyond a single LAN. Multicast that crosses network boundaries is SSM in practice:
RFC 8815 deprecates inter-domain ASM.
ISP and enterprise deployments carry channels in the SSM ranges (232/8, FF3x::/32), where routers only forward source-specific joins (RFC 4607).
AMT relays (RFC 7450) serve (S,G) channels.
That is the TreeDN model (RFC 9706): native multicast where the network supports it and AMT relays everywhere else.
Who’s asking:
My company, Blockcast. We built that workaround, and this intent lets us drop it on native networks.
2026-10-21, 08:37 PDT — Alex Russell:Thanks Omar, LGTM1 2026-10-21, 09:38 PDT — Chris Harrelson:LGTM2 2026-10-21, 08:24 PDT — Mike Taylor:LGTM3
Three LGTMs inside a single morning, one business day after the requester’s answer. The intent is approved.
The answer itself is technically substantial, and it is worth reading for the specific claim it makes about ASM’s limits. The argument is not “multicast is useful” — that was already settled when base multicast shipped. It is that inter-domain multicast is SSM in practice, because that is what the routing infrastructure carries:
RFC 8815 deprecates Any-Source Multicast for inter-domain use.
RFC 4607 defines SSM, and ISP and enterprise networks carry their channels in the SSM address ranges — 232.0.0.0/8 for IPv4, ff3x::/32 for IPv6 — where routers only forward source-specific joins.
RFC 7450’s AMT (AMT Relay) serves (S,G) channels.
RFC 9706 defines TreeDN, the combination.
The practical consequence for a browser application is the punchline of the whole thread. An ASM-only client cannot receive those channels off its own LAN. Its only workaround is to write an AMT gateway in JavaScript over unicast UDP, which requires a relay and throws away native delivery everywhere the network would have supported it. The Blockcast Multicast Gateway (BEACON) Chrome extension is exactly that workaround, so the feature owner had an existing implementation to point at.
Harald Alvestrand had made the same argument back in November 2025 in WICG/direct-sockets#80, which closed the same month the spec PR landed:
Basically Any-Source Multicast with a scope larger than your local network is a high-powered DDOS vector, not something that it’s safe to build services on, so I heartily recommend adding support for Single-Source Multicast.
The intent also notes the precedent: Node.js’s dgram module has had addSourceSpecificMembership / dropSourceSpecificMembership for years.
The order is strange on purpose
Here is the part that reads like a bug and is not. The chromestatus entry records the feature as:
1 2 3
browsers.chrome.status: "Enabled by default" browsers.chrome.desktop: 158 browsers.chrome.announced: true
Chrome 158 branched on 2026-10-21 — the same day the third LGTM landed. The feature was therefore in the tree and enabled by default before its intent process concluded. That is not how the pipeline is supposed to work; normally the intent comes first and the implementation follows.
What happened is a replay of the sequence this blog recorded on 2026-10-12: commit 3076e5f5 landed the SSM implementation in the network-service and Blink layers, then seven months later was reverted —
Reason for revert: Suspicious about causing test failures of ChromeDirectSocketsUdpIsolatedWebAppMulticastTest.multicastJoinGroupSSMSameGroupDifferentSources at ci.chromium.org/ui/p/chromium/builders/ci/mac15-x64-rel-tests/15107.
— because of a flaky macOS test, not because of a design objection. The implementation came back, this time under the two-week release cadence that Chrome adopted in mid-2026, and shipped in 158. The intent process then had to catch up to a shipped reality and confirm that shipping it was still the right call.
That is the honest framing: the LGTMs are not pre-approval of a design, they are a ratification of a shipped one. The “is anyone asking for this” question from Alex Russell is what makes it a ratification rather than a rubber stamp — the answer had to still be good in October 2026, not just in the November 2025 prototype.
The spec and the implementation do not agree
The second piece of news is quieter and, for anyone writing code against the API today, more immediately useful.
WICG/direct-sockets#86 — “Multicast: disallow ASM/SSM mixing per group, validate leaveGroup(), handle close” — was opened 2026-10-14 by kkroo and is still open. Its description is a list of five concrete divergences between the spec text and what Chromium actually does:
joinGroup() throws an InvalidStateError when the socket already has, or is joining, a membership of the other kind for the same groupAddress. That means an SSM join while an ASM membership exists, or an ASM join while SSM memberships exist. The explainer already states this rule (“Mixing ASM and SSM memberships for the same group address is not allowed”).
leaveGroup() validates groupAddress (multicast) and options.sourceAddress (unicast, not loopback or unspecified, same IP version) the same way joinGroup() does. It throws a TypeError before the InvalidStateError membership check.
Both methods check for a closed socket first, before validating their arguments.
leaveGroup() throws an InvalidStateError while the join is still pending, because [[joinedGroups]] only holds completed joins.
Closing the socket rejects pending joinGroup() and leaveGroup() promises with an AbortError, and joinedGroups is empty afterwards (Chromium’s MulticastController::OnCloseOrAbort()).
Point 5 is the one with real architectural weight, and it is worth spelling out. The spec previously had no defined way to cancel a pending joinGroup(). A joinGroup() call issues a setsockopt(MCAST_JOIN_GROUP) against the network service; if the socket is closed while that is in flight, the promise had no defined settlement path. Chromium’s implementation answers it with AbortError, and the spec PR writes that down.
This is not a theoretical concern. The whole reason SSM exists is that MCAST_JOIN_SOURCE_GROUP is a privileged operation that a browser’s network-service sandbox has to allow explicitly. The original implementation landed without those setsockopt codes in the BPF allowlist, and every SSM join attempt hit the sandbox’s CrashSIGSYSSockopt() and killed the process:
Root cause: MCAST_JOIN_SOURCE_GROUP / MCAST_LEAVE_SOURCE_GROUP were not in the BPF setsockopt allowlist for the network service sandbox. Any SSM setsockopt call hit CrashSIGSYSSockopt(), killing the process.
Fix: crrev.com/c/7641869 — MCAST_JOIN_SOURCE_GROUP and MCAST_LEAVE_SOURCE_GROUP added to both the IPv4 and IPv6 setsockopt case lists in RestrictSetSockoptForNetworkService().
So a join can fail slowly — after a network round trip — and the failure mode has to be specified, because “the socket closed while your join was in flight” is a completely ordinary thing for an application to do during teardown.
The shape of the API
For reference, the API surface is small. MulticastController is obtained from the multicastController member of the dictionary returned by a bound UDPSocket‘s opened promise, and it exposes exactly three things:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// The socket must be in bound mode. const socket = newUDPSocket({ remoteAddress: "0.0.0.0", remotePort: 0 }); const { readable, writable, multicastController } = await socket.opened;
// Any-Source Multicast — the classic form. await multicastController.joinGroup("239.0.0.1");
// Source-Specific Multicast — only packets from this source are delivered. await multicastController.joinGroup("239.0.0.1", { sourceAddress: "192.168.1.50", });
// Introspection: a DOMString for ASM, a MulticastMembership for SSM. const groups = multicastController.joinedGroups;
The joinedGroups return type is a deliberate feature-detection mechanism, and it is the reason the intent could claim the feature is low-risk:
Low. sourceAddress is an optional dictionary member: code that doesn’t pass it behaves exactly as before, and joinedGroups still returns strings for ASM memberships. Where SSM is unavailable (older Chrome, or the feature turned off), sourceAddress is ignored and joinGroup() makes an ASM join; apps can detect this because joinedGroups then lists a string, not a MulticastMembership.
That is a well-constructed backwards-compatibility story. An application that wants SSM can call joinGroup(group, { sourceAddress }) unconditionally and then inspect joinedGroups to find out whether it actually got a source-filtered membership or silently degraded to ASM. No feature detection branch at the call site, no version sniffing.
The mismatches that are being deferred
The PR is honest about what it does not fix. These gaps predate SSM and apply to ASM as well:
The spec compares groupAddress and sourceAddress as strings. Chromium compares the parsed IP addresses, and joinedGroups returns their canonical form (FF02::1 is listed as ff02::1).
The spec orders joinedGroups by join time. Chromium returns hash-map order.
The spec rejects every OS failure with NetworkError. Chromium maps some errors to DataError, NotSupportedError, InvalidAccessError or InvalidStateError.
That third bullet is the one that will bite application code. A caller that writes catch (e) { if (e.name === "NetworkError") { /* retry */ } } is wrong against the shipped implementation for at least four distinct error classes. A robust caller has to treat the DOMException name as an open set.
The PR also notes that the spec already requires rejecting a loopback sourceAddress, and that Chromium aligns with that in CL 8582708 — a loopback source would defeat the entire security model of SSM, since RFC 4607’s model assumes a routable source.
What this means for webadb.online
Direct impact on webadb.online today: none, and that is by design.
webadb.online reaches devices over WebUSB, not over Direct Sockets. The Direct Sockets API is gated behind Isolated Web Apps, and IWAs are ChromeOS-only for now — an IWA cannot be installed on the Android phone that is plugged into the user’s laptop, and an IWA cannot ask for a WebUSB device the way a normal page can. The two mechanisms are complementary rather than substitutable, and webadb.online uses the one that works in a regular tab on any platform.
But the reason to keep tracking this API is specific: Direct Sockets multicast is the only browser-reachable mechanism for mDNS-style device discovery, and mDNS discovery is what a Wi-Fi-ADB workflow needs. Today, webadb.online’s Wi-Fi ADB path requires the user to type the device’s IP address and port by hand. If an IWA can join a multicast group and listen for mDNS responses, discovery becomes automatic.
The shape of that future is now much clearer, and it comes with the caveats this post has been documenting:
SSM is the model that actually works off-LAN. The intent thread’s central argument — that inter-domain multicast is SSM in practice (RFC 8815, RFC 4607, RFC 7450, RFC 9706) — is exactly the argument for an ADB-over-network feature that has to work on a corporate Wi-Fi network rather than only on a home LAN. An ASM-only client on an enterprise network would receive nothing at all, because enterprise switches commonly filter or drop ASM joins outside the local scope.
The API degrades gracefully, but the detection is non-obvious. Because sourceAddress is silently ignored where SSM is unavailable, the joinedGroups return-type difference is the only reliable signal. Any webadb code that adopted this must check the shape of the returned value, not just its presence.
The error semantics are still in flux. PR #86 is open. The AbortError on socket close, the TypeError-before-InvalidStateError ordering in leaveGroup(), and the InvalidStateError for ASM/SSM mixing are all documented-but-unmerged. Writing against the spec today means writing against a spec that does not yet match Chrome 158.
The third point is the general lesson, and it applies well beyond this API. The Chromium feature pipeline can ship a feature enabled-by-default and then run its intent process, and the specification can lag the implementation by months. When you are writing against a browser API that just shipped, the implementation is the ground truth and the spec is a roadmap.
One closing note on the upstream ADB side, for completeness: ya-webadb itself was completely quiet this week. The repository’s main branch still sits at 3a7d781c (2026-10-27, the scrcpy inject-key shorthands), there were no new releases since v3.0.0-beta.4 on 2026-10-09, and no issue or pull request in the repository was updated in the last 24 hours. scrcpy upstream is likewise unchanged since 2026-10-08. Two posts ago this blog covered the withdrawal of ya-webadb PR #875; that remains the most recent movement in the library, and there is nothing new to report on it.
Timeline
For readers who want the whole arc in one place:
Date
Event
2025-09-23
Explainer for multicast in Direct Sockets merged (WICG#78)
Chrome’s PermissionsPolicyForSharedWorkers feature landed a five-CL series into refs/heads/main between 2026-09-30 and 2026-10-28, giving shared workers an allow attribute that carries a parsed permissions policy into the worker’s global scope. That is the missing half of the “Direct Sockets API in Shared/Service workers” entry on chromestatus, which has sat at Proposed with no milestone since 2025-01-31.
It is also, incidentally, the reason this week’s check turned up something stranger: the chromestatus entry 6046077976444928, “Permission Policy Merger: direct-sockets-private with local-network and loopback-network“, is still describing a permission policy that Chromium deleted outright four months ago. It says Proposed, milestone 151, and it describes a “default unpacking” model that no longer exists in the tree.
Both facts are worth writing down carefully, because the gap between what the status page says and what the tree does is exactly the kind of thing that sends an implementer down the wrong path.
The chromestatus entry that outlived its implementation
The entry’s summary reads:
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.
Its stage table still lists a milestone-151 stage, and its browsers.chrome block still says:
The updated field on that entry is 2026-06-22 13:32:10, by [email protected]. That is the last time anyone touched it.
Now the tree. Chromium commit f1b445b81bac — “Remove direct-sockets-private permission policy”, 2026-06-12 — did substantially more than delete a policy name:
Its commit message is one sentence, and it is the important sentence:
This CL removes “direct-sockets-private” permission policy along with its default unpacking to “local-network” and “loopback-network” permission policies.
Note the shape of that sentence. “Direct Sockets private” was not renamed to the pair, and it was not unpacked into the pair at runtime any more — it was removed, and with it went the unpacking behavior. The two granular policies now stand on their own.
The diff shows the scale of it. kDirectSocketsPrivate disappears from services/network/public/mojom/permissions_policy.mojom, where the enum value is not deleted but reserved:
reserved 4 is the protobuf idiom for “this number was used, do not reuse it.” The value that was DIRECT_SOCKETS_PRIVATE = 4 is now a hole. The same pattern shows up in the IWA entitlement proto:
"direct-sockets-private" is gone from the entitled-features list. The IwaEntitlementProto::DIRECT_SOCKETS_PRIVATE enum value is gone from key_distribution.proto, and the corresponding entitlement line is gone from key_distribution.textproto — the file that ships the allowlist to ChromeOS kiosk deployments.
The browser-side enforcement path
The interesting part for anyone reading the tree today is in content/browser/direct_sockets/direct_sockets_service_impl.cc. Before the removal, the private-network access request path looked like this:
1 2 3 4 5 6 7 8 9 10
if (!rfh->IsFeatureEnabled( network::mojom::PermissionsPolicyFeature:: kDirectSocketsPrivate)) { std::move(callback).Run(/*access_allowed=*/false); return; }
After, that whole first block is gone. The kDirectSocketsPrivate gate is not replaced by a kLocalNetwork / kLoopbackNetwork gate in that function — the permission check moved elsewhere, to the policy-controlled-feature machinery that local-network and loopback-network already participate in as ordinary policies.
This is the part that trips people up when they read the chromestatus entry and then try to find the corresponding code. The “merger” the entry describes is not a merger in the current tree; it is a removal, and the granular policies were already there.
What the removal meant in practice
The browsertest diffs are the clearest record of the behavior change, because the policy declarations are explicit in them. Before:
Note that both granular policies are now declared. local-network covers RFC 1918 address space; loopback-network covers 127.0.0.0/8 and ::1. An app that wants a Direct Sockets connection to a LAN device needs the first; one that wants to talk to a server on the same machine needs the second.
And the merge-time “unpacking” behavior is gone from the test suite too. The commit deletes a whole test class from chrome/browser/web_applications/isolated_web_apps/isolated_web_app_permissions_policy_browsertest.cc:
143 lines of test, all of it testing the unpacking. It is gone. If you go looking for that behavior to understand how direct-sockets-private used to work, the answer is that it does not work — the mechanism was removed along with the policy.
Why the entry was never updated
The plausible explanation is that the removal was framed as a cleanup rather than a feature change. The CL description says “removes … along with its default unpacking” — that is a correctness framing, because the unpacking was doing something surprising. But the chromestatus entry 6046077976444928 is still the entry a developer lands on when they search “Direct Sockets” and want to know what manifest permissions to write. Its updated timestamp is ten days before the commit that invalidated it, and its last edit was 2026-06-22 — ten days after. So the entry was already being maintained after the removal, and still describes the old model.
For our own project this has a concrete consequence: any note we write about “the merger” is describing a mechanism that no longer exists in the tree, and any manifest we write should declare local-network and loopback-network directly rather than expecting a direct-sockets-private declaration to be unpacked into them.
The PermissionsPolicyForSharedWorkers series
Now the genuinely new work. The chromestatus entry 5169039028256768, “Direct Sockets API in Shared/Service workers”, reads:
Allows Isolated Web Apps to establish direct transmission control protocol (TCP) and user datagram protocol (UDP) communications with network devices and systems as well as listen to and accept incoming connections from shared /service workers.
The Direct Sockets API has been launched in Chrome 131 for window & dedicated worker contexts; this separate entry aims at expanding these to accommodate shared / service workers to achieve parity with the exposure of other networking capabilities such…
No milestone. Not announced. Created 2025-01-31, and the entry’s updated field has not moved since 2025-02-12.
That entry is not going to move on its own. What is moving is the permissions-policy plumbing that a shared-worker Direct Sockets implementation would need — because a shared worker that can open raw TCP sockets is exactly the kind of thing that needs a policy to constrain it, and Chromium had no way to express a per-worker policy at all.
The five CLs
Tracking bug 562705553. All five land on refs/heads/main:
(The first CL is from 2026-09-30; the last is from four days ago. The series was reviewed by Yoshisato Yanagisawa, Daniel Vogelheim, Hiroshige Hayashizaki, Camille Lamy, Antonio Sartori, Mike West, and Reilly Grant.)
CL 1 — the feature and the IDL
8c2c2dfb5fb2 adds the runtime-enabled feature and the optional IDL member:
Define the PermissionsPolicyForSharedWorkers runtime-enabled feature with experimental status and enable it with –enable-experimental-web-platform-features in the browser process.
Add the optional allow DOMString member to the SharedWorkerOptions IDL dictionary behind PermissionsPolicyForSharedWorkers, making it available to the SharedWorker constructors.
This mirrors the WorkerOptions shape that the HTML standard specifies for dedicated workers. A dedicated worker gets its allow list from the WorkerOptions dictionary; a shared worker gets its own, because a shared worker is instantiated by many documents and the policy has to be agreed upon by all of them.
Parse the allow attribute on SharedWorkerOptions using PermissionsPolicyParser::ParseAttribute() in SharedWorker::CreateImpl() when PermissionsPolicyForSharedWorkers is enabled, and log any parser messages to the execution context console.
The relevant part of the diff is in shared_worker.cc — a PermissionsPolicyParser is constructed, ParseAttribute() is called with the allow string, and the resulting feature list is checked.
Verify in SharedWorker::CreateImpl() that the creator ExecutionContext can delegate to the worker origin every feature parsed from SharedWorkerOptions.allow, and trigger Sharedworker.onerror otherwise.
This is the same rule that applies to WorkerOptions.allow in a dedicated worker: the creator document may only delegate features it itself holds. A page that does not have direct-sockets cannot create a shared worker that has direct-sockets. If it tries, the construction fails and Sharedworker.onerror fires.
The delegation machinery itself is added to the network service’s policy code:
c40dcca70c04 pushes the parsed policy from the renderer into the browser process:
This CL adds permissions_policy as a parameter to blink.mojom.SharedWorkerInfo(), forwards the parsed permissions policy in the ‘allow’ attribute from SharedWorker::Create() to the browser process and validates browser-side whether the policy is allowed.
And it is the CL that adds the browser-side half of the delegation check — permissions_policy.cc and permissions_policy.h each grow a dozen lines, and permissions_policy_mojom_traits.h grows fifteen.
CL 5 — into the worker’s global scope
1c9716f07802 finally stores it where the worker can consult it:
Pass the worker’s parsed permissions policy in the SharedWorkerInfo sent via SharedWorkerFactory::CreateSharedWorker(), plumb it to WebSharedWorkerImpl::StartWorkerContext() and store it in the SecurityContext of the newly created WorkerGlobalScope.
The diff in web_shared_worker_impl.cc is where the policy actually gets applied:
1 2 3 4 5 6 7 8 9 10 11 12
if (RuntimeEnabledFeatures::PermissionsPolicyForSharedWorkersEnabled()) { const url::Origin worker_origin = (creation_params->origin_to_use ? creation_params->origin_to_use.get() : outside_settings_object->GetSecurityOrigin()) ->ToUrlOrigin(); creation_params->worker_permissions_policy = network::PermissionsPolicy::CreateFromInheritedPolicy( // TODO(https://crbug.com/562705553): Parse the Permissions-Policy // header and take header_policy into account. permissions_policy, /*header_policy=*/{}, worker_origin); }
Two things worth noticing in that snippet.
First, CreateFromInheritedPolicy is a new factory method added by this CL, sitting next to the existing CreateFromParsedPolicy:
Second, the TODO is honest about a gap: the Permissions-Policyresponse header is not parsed yet — only the allow attribute is. The /*header_policy=*/{} argument is an empty parsed policy. That is a real limitation of the current implementation, not a documentation nicety.
The same CL also removes a TODO that had been sitting in content/browser/worker_host/shared_worker_host.cc — the one that said the permissions policy was not yet stored on the SharedWorkerInstance and sent down to the renderer:
1 2 3 4 5
- // TODO(https://crbug.com/562705553): Store the shared worker's - // permissions policy on the SharedWorkerInstance and send it down to the - // renderer process. - network::PermissionsPolicyFeaturesBitset())); + instance_.permissions_policy()));
CL 6 — flag consistency
e8113630b3fd, four days ago, is the one that makes the previous CLs safe to actually enable:
This CL ensures that the runtime enabled flag PermissionsPolicyForSharedWorkers in blink can only be enabled in conjunction with the counterpart blink feature. That is necessary because part of the functionality is implemented in the browser process.
The problem it solves: Blink has a runtime-enabled feature (RuntimeEnabledFeatures::PermissionsPolicyForSharedWorkersEnabled(), used above in web_shared_worker_impl.cc), and the browser process has a base::Feature (blink::features::kPermissionsPolicyForSharedWorkers). If only one of the two is on, the renderer will consult a policy that the browser never validated. So content/child/runtime_features.cc now resolves the mismatch at startup:
1 2 3 4 5 6 7 8 9 10 11
if (!base::FeatureList::IsEnabled( blink::features::kPermissionsPolicyForSharedWorkers)) { LOG_IF(WARNING, WebRuntimeFeatures::IsPermissionsPolicyForSharedWorkersEnabled()) << "PermissionsPolicyForSharedWorkers cannot be enabled in this " "configuration. Use --" << switches::kEnableFeatures << "=" << blink::features::kPermissionsPolicyForSharedWorkers.name << " instead."; WebRuntimeFeatures::EnablePermissionsPolicyForSharedWorkers(false); }
And runtime_enabled_features.json5 gains one line to match:
public: true means the Blink-side runtime feature is exposed to JS as self.PermissionsPolicyForSharedWorkers, so a page can feature-detect it instead of guessing.
Why this is the Direct Sockets prerequisite
The connection to Direct Sockets is not speculative. The chromestatus entry for workers-scoped Direct Sockets is Proposed with no milestone, but the Chromium flags that gate it were explicitly extended last month.
Commit 7dba2c9e4dc0, 2026-05-08, “Extend the direct-sockets-in--worker flags”*:
This commit extends the direct-sockets-in-shared-workers and direct-sockets-in-service-workers feature flag to M170. The feature is not launched yet.
The diff is in chrome/browser/flag-metadata.json, and it is only about expiry dates:
Both flags now expire at milestone 170 — roughly a year of runway past where they were, and a clear signal that Chromium intends to launch the feature before then.
The history of those flags is worth a short walk, because it shows the shape of the problem:
cea39453dc19 (2025-02-11) — “Add #direct-sockets-in-service-workers flag to about://flags”
f807b7164793 (2025-02-10) — “DirectSockets: support shared workers on the browser side”
The browser side of worker-scoped Direct Sockets has been in the tree for about twenty-one months. What it did not have, until this series, was a permissions-policy mechanism to constrain what a shared worker may do. A shared worker opened by many documents needs an agreed policy, and there was no way to express one.
That is exactly what PermissionsPolicyForSharedWorkers adds. The allow attribute on SharedWorkerOptions is the standard HTML shape for “this worker gets these features, and every creator must be able to delegate them.”
The allow list is a list of feature names — and the feature names for Direct Sockets are direct-sockets, direct-sockets-multicast, local-network, and loopback-network. A shared worker that wants to open a TCP socket to a LAN address would need:
That is the shape the Chromium implementation now supports.
The state of the board
Pulling it together, here is where each piece stands as of 2026-11-02:
Entry
chromestatus status
Tree reality
direct-sockets (core API)
Enabled by default, 131
shipped
Source-Specific Multicast
Enabled by default, 158
shipped (030237772913, 863435829ee0)
direct-sockets-private merger
Proposed, 151
removed 2026-06-12 (f1b445b81bac)
Direct Sockets in Shared/Service workers
Proposed, no milestone
browser side shipped 2025-02; flags extended to M170 2026-05-08; permissions-policy plumbing landed 2026-09-30 → 2026-10-28
WebRequest.SecurityInfo in Controlled Frame
Proposed, 145
unchanged since 2026-01-27
Two rows in that table are stale, and they are stale in opposite directions. The merger entry overstates a change that was reverted into a plain removal. The worker entry understates work that has been landing in the tree for five weeks.
Neither is a problem for webadb.online today — we use WebUSB, not Direct Sockets, in the shipped product. But lib/adb-daemon-tcp.ts implements the wireless-ADB-over-TCP transport on top of navigator.openTCPSocket(), and that file’s comments are written against the old mental model:
1 2 3 4 5 6 7 8 9
// Browser requirements: // - Chrome 142+ (we tested 150.0.7871.114) // - One of: // * Origin Trial token for "Direct Sockets API" (added to the page) // * chrome://flags/#enable-experimental-web-platform-features enabled // * Enterprise policy allow-list // - The page must be served over HTTPS (webadb.online is) and must already // pass the crossOriginIsolated check (webadb does). Direct Sockets // requires `crossOriginIsolated === true` to even be visible.
The “Origin Trial token” line is stale in a different way: the Direct Sockets API shipped enabled-by-default in Chrome 131 for window and dedicated worker contexts, so there is no origin trial to enroll in for the main use case. The local-network / loopback-network policies are the current gating mechanism, not direct-sockets-private. And the “Chrome 142+” floor is conservative — the API has been on by default since 131.
What this means for webadb.online
No impact on the shipped product this week. The Direct Sockets transport in lib/adb-daemon-tcp.ts remains an experimental Wi-Fi-ADB path behind a browser capability probe, and nothing that landed in the last seven days changes the transport’s availability on a normal page.
Two things are worth watching, though.
The first is that the PermissionsPolicyForSharedWorkers series is the first concrete movement in the worker-scoped Direct Sockets path in months. If the direct-sockets-in-shared-workers flag is launched before M170, the natural shape for a browser ADB client is a shared worker owning a persistent TCP connection to the device’s wireless-debug port, with the main thread talking to it over a MessagePort. That would move the TCP framing off the UI thread entirely. The flag expiry bump to M170 is the visible signal that launch is planned; the permission-policy plumbing is the first blocker cleared.
The second is the chromestatus merger entry. If we ever document the Direct Sockets requirements for the wireless-ADB transport, we should describe local-network and loopback-network as the policies to declare — not direct-sockets-private, and not a “merger” that unpacks one into the other. Chromium removed that mechanism on 2026-06-12. The status page is still telling people to write a policy name that no longer exists.
Upstream ya-webadb had no new commits in the last 24 hours — the most recent are 59e95a0 and 93fce38 from 2026-10-30, both covering the PCM player’s AudioContext resume, which the 2026-10-31 post already documented. The v3.0.0-beta.4 tag from 2026-10-09 remains the newest release.
Two upstream changes landed in ya-webadb on 2026-10-30, and they point in the same direction: making the library viable as a real-time media client in a browser tab. The first gives @yume-chan/pcm-player a pool of shared AudioContexts and an auto-resume path that survives the browser’s autoplay policy (93fce383). The second bumps the zstd compression adapter’s WASM codec from 0.0.58 to 0.2.6, a range that includes a simd128 WebAssembly build (59e95a0e).
Neither change is a user-visible feature release. Both are the kind of change that determines whether a feature can exist, though, and both are directly relevant to webadb.online: the audio-context work removes a browser-policy failure mode that any future audio panel would hit, and the zstd bump is the prerequisite for the compressed ADB sync transport that would make pushing and pulling large files over WebUSB practical.
This post walks through what the shared-context change actually does, why the stop() semantics had to change with it, and what the zstd version jump means for a browser-based ADB client.
The problem with one AudioContext per player
The pre-commit PcmPlayer constructor looked like this:
That works, but it has two costs that only show up once you have more than one player, or once you stop and restart.
The first cost is resource duplication. An AudioContext is not a lightweight object. It owns an audio rendering thread, its own clock, and — once you touch audioWorklet — a separate rendering graph with its own allocation. Two players at 48 kHz stereo means two rendering threads, two clocks, and two independent latency measurements. The outputLatency getter on PcmPlayer reports whichever context that particular player happens to own, so with N players you get N different latency numbers that do not agree with each other.
The second cost is the stop() → restart → dead context problem, and this is the one that actually breaks. AudioContext.close() is irreversible. The README documents the consequence directly:
1 2 3 4
var player = Int16PcmPlayer(44100); player.start(); player.feed(newInt16Array([0, 0, 0, 0, 0, 0, 0, 0])); player.stop(); // `AudioContext` will be closed, so can't be restarted
So the old API made a player a one-shot object. There was exactly one lifecycle: construct → start → feed → stop. If you wanted to play a second clip after the first, you had to construct a new player, which meant a newAudioContext, which meant paying the full construction cost again.
The commit changes this in three coordinated moves.
The pool is keyed by sampleRate and nothing else. That is the right granularity: an AudioContext has a single fixed sample rate for its entire lifetime — you cannot change it after construction — so two players can only share a context if they agree on the rate. The latencyHint: "interactive" setting is preserved from the old per-player construction, so pooling does not change the latency class.
The constructor now takes an optional context and falls back to the pool:
The optional third parameter is the escape hatch. A caller that wants complete control over the context — an OfflineAudioContext for rendering to a buffer, or a context with a specific latencyHint — can pass one in. A caller that does not care gets the shared one. Note the parameter is positional-optional rather than an options bag, which is slightly unusual for the codebase but keeps the diff minimal.
Channel count is not part of the pool key, and it does not need to be. AudioWorkletNode takes outputChannelCount per node, so two players sharing one context can output different channel counts:
audioWorklet.addModule() compiles and instantiates the processor’s JavaScript. It is not free, and it is per-context: each AudioContext has its own audioWorklet namespace and needs its own module registration. So the natural optimization is to not re-add the module for the same context twice.
The WeakMap is the interesting part. It keys on the context object, and weak keys mean the entry disappears when the context is garbage collected — no leak if a caller manages their own context lifecycle outside the pool. It stores the promise, not the resolved value, so that two concurrent start() calls on the same context share one in-flight addModule() rather than issuing two compilations. The old code called addModule() on every start(), which meant every player start triggered a fresh worklet compilation even when the module was already registered in that context.
Move three: the context is no longer closed on stop
1 2 3 4 5 6 7 8 9 10 11 12 13
stop() { if (this.#stopped) { return; } this.#stopped = true;
// Do not close the AudioContext as it might be shared. }
This is the change that requires the other two. Because the context may be shared, stop() cannot close it — closing it would kill every other player using the same context. Instead it disconnects the worklet node and drops the buffered samples.
Two details are easy to miss:
stop() is no longer async. The old signature was async stop() because it awaited this.#context.close(). With the close removed there is nothing to await, and the follow-up commit (59e95a0e) drops the async keyword entirely, which is what the lint fix in that commit was actually about. This is a breaking API change for anyone who was awaiting stop() — await on a non-promise is harmless in JS, so existing call sites keep working, but the type signature changes from Promise<void> to void and TypeScript will notice.
this.#buffers.length = 0 is new. Previously the buffers were left alone on stop(). Now they are explicitly cleared, which is correct: the buffer holds queued samples that were fed before start() was called, and after stop() they are dead weight that would be replayed if the player were somehow restarted.
The #stopped flag still makes the player one-shot in the same sense as before — feed() throws new Error("PcmPlayer is stopped") after a stop, and start() early-returns if #stopped is set. What changed is that stopping a player no longer destroys the shared context, so a new player created afterwards can immediately use it.
The auto-resume path and the autoplay policy
The other half of the feature is #tryResuming:
1 2 3 4 5 6 7 8 9 10 11
/** * Starting the AudioContext if it is suspended and the user has interacted with the page. */ #tryResuming() { if ( this.#context.state === "suspended" && navigator.userActivation.hasBeenActive ) { voidthis.#context.resume().catch(() => {}); } }
This is the part that matters most in practice, because it deals with Chrome’s autoplay policy.
An AudioContext created before any user gesture starts in the suspended state. The audio graph exists, process() calls still fire on the rendering thread, but the output is not connected to the destination — or, more precisely, the context’s output produces silence. This is Chrome’s way of saying “you may not make noise until the user has interacted with the page.” The context transitions to running on a user gesture, and context.resume() can also do it from a gesture handler.
The problem for a streaming audio client is timing. Consider: the user clicks “Connect device”, which is a user gesture. The ADB session comes up, the shell starts, and the PCM stream begins flowing a few seconds later. By the time the first samples arrive, is the context still running?
In the good case, yes. In the bad case, no — and the reasons are subtle:
The gesture was in a different task. Chrome’s autoplay policy tracks whether the document has ever had a user activation, not whether the current call stack descends from a gesture. navigator.userActivation.hasBeenActive captures exactly that broader question, and the check in #tryResuming uses it.
Something suspended the context. Chrome suspends an AudioContext when the page goes to the background, when the tab is hidden, or when the device’s audio session is interrupted (a phone call on the host machine, another app taking exclusive audio). The context comes back suspended after the tab is foregrounded again, with no user gesture involved.
The context was created lazily inside the pool. If the first player for a given sample rate is constructed after the gesture, its context is born post-activation and starts running. But if the pool already had a context from before the gesture, the new player inherits a suspended one.
In all three cases, the symptom is identical: the worklet is running, the samples are being consumed, the buffer counters are moving, and the user hears nothing. There is no error, no exception, no console warning. The panel would just sit there mute, and the natural debugging instinct — “maybe the stream is broken” — points in exactly the wrong direction.
The fix is to attempt a resume opportunistically at every point where samples enter the system:
1 2 3 4 5 6 7 8 9 10 11 12 13
feed(source: T) { if (this.#stopped) { thrownewError("PcmPlayer is stopped"); }
if (this.#worklet === undefined) { this.#buffers.push(source); return; }
and again in start(), after the worklet is connected:
1 2 3 4 5 6 7
this.#worklet.connect(this.#context.destination);
this.#tryResuming();
for (const source ofthis.#buffers) { this.feedCore(this.#worklet, source); }
Note the feed() placement — the resume attempt happens after the #worklet === undefined early return. Before start(), there is no worklet to feed, so the samples are queued and no resume is attempted. That is correct: resuming a context with no connected output graph would just start the rendering thread producing silence.
Two defensive details are worth calling out:
navigator.userActivation.hasBeenActive is checked before resume() is called. Calling resume() without an activation is rejected by the browser, and an unhandled rejection in a void-ed promise is exactly the kind of thing that shows up as an uncaught error in the console and hides the real problem. Checking first means the common case (user has interacted at some point) attempts the resume, and the rare case (no activation ever) silently does nothing.
The .catch(() => {}) swallows the rejection.resume() can fail for reasons other than the autoplay policy — the context might have been closed, or the browser might refuse for a reason that is not worth interrupting the audio pipeline over. Swallowing it keeps the audio path from throwing, and the user-visible symptom (still muted) is the same as before, so there is no information lost.
navigator.userActivation is a fairly recent addition to the platform (Chrome 77+), which is well within the baseline for anything that supports WebUSB and Web Audio anyway.
What the worklet actually does with the samples
The worker/worker.ts file is unchanged by this commit, but it explains why the buffering behaviour in PcmPlayer is shaped the way it is.
The processor is a resampler with an overlap-add (OLA) window. SCALE = 0.9 means the input is consumed at 90% of real time — 3000 input samples per hop produce 2700 output samples. That 10% headroom is what lets the processor speed up when it falls behind:
Once the backlog exceeds a quarter second of samples, the processor switches into #speedUp mode and runs the Hann-weighted OLA window to burn through the excess rather than playing it all in real time. There is a hysteresis band — it drops back out of speed-up mode only when the backlog falls below 0.15 seconds.
The practical implication for the PcmPlayer API is that feed() before start() is not an error, it is queued.
1 2 3 4
if (this.#worklet === undefined) { this.#buffers.push(source); return; }
Samples fed before the worklet exists are held and replayed once start() connects the node. And start() is where the context finally becomes eligible to run:
The if (this.#stopped) return; check after the await is a race-condition guard: addModule() is asynchronous, so stop() could have been called while the module was compiling. Without the check, the player would create a worklet node and connect it to a context that the caller already stopped using.
The zstd jump: 0.0.58 → 0.2.6
The second change is a dependency bump that crosses the 0.1.0 boundary, and it is easy to miss because it is buried in a commit whose subject line is “fix lint error”.
The diff in libraries/adb-compression-zstd/package.json:
Eleven releases in twenty-six days, and the ya-webadb bump landed on 2026-10-30 — four days after 0.2.6 shipped. The package is a pure-Rust Zstandard implementation compiled to WebAssembly, and the 0.1.0 release is where it stabilized its public API:
1 2 3 4
import { createCompressStream as createCompressStreamRaw, createDecompressStream as createDecompressStreamRaw, } from"@structured-world/structured-zstd";
What the codec actually does for an ADB client
Zstandard in the ADB protocol is not a cosmetic feature. The ADB sync protocol (sendrecv_v2) supports multiple compression algorithms for file transfer, and the device advertises which ones it accepts in its feature string:
cmd and shell_v2 are the shell protocol versions. sendrecv_v2_zstd means the device will accept zstd-compressed sync traffic. Modern Android devices advertise all three of brotli, lz4, and zstd.
Without a compression adapter, a browser-based client falls back to the uncompressed sync path. For webadb.online’s File Manager, that means every adb push of an APK or an APK split — files that are already compressed and therefore barely compressible — goes over WebUSB uncompressed, and files that are compressible (text, images, resources) pay full size.
The SIMD part
The upstream README describes the two-payload design:
Automatic SIMD. Ships two payloads — one built with the WebAssembly simd128 SIMD tier, one scalar — and picks the fast one at runtime via engine feature detection. You import one package; the right .wasm loads.
And the selection mechanism:
WebAssembly has no runtime CPU detection, so SIMD selection is an engine capability probe (wasm-feature-detectsimd()): when the host supports wasm SIMD the simd128 payload loads, otherwise the scalar one. In Node.js the .wasm bytes are read from disk; in the browser/Deno they are fetched relative to the module.
This matters for a browser ADB client in a specific way: the codec runs in a Web Worker, off the main thread, so the SIMD gain translates directly into sync throughput rather than into UI jank. The ya-webadb adapter is already worker-aware — registerZstdCompression accepts a worker option that defaults to "auto", meaning “use a worker if we are on the main thread”:
Combined with a simd128 build, this is the difference between a zstd adapter that keeps up with a USB 3 connection and one that does not.
The streaming API is what the sync protocol needs
The 0.1.0 API added incremental streaming, which is what a sync transfer actually needs — you cannot buffer a 200 MB APK in memory to compress it in one shot:
1 2 3 4 5
const stream = awaitcreateCompressStream(19); constout: Uint8Array[] = []; forawait (const chunk of source) out.push(stream.push(chunk)); out.push(stream.finish()); // final block + checksum, seals the frame stream stream.free(); // release the wasm handle
The key property, from the upstream README: push(chunk) returns the compressed bytes complete so far, and “the frame omits Frame_Content_Size (unknown while streaming) yet decodes in any compliant zstd decoder, including the native C library.” That omission matters for interop — a normal zstd frame header declares its decompressed size so the decoder can allocate the output buffer in one shot, but a streaming frame cannot know that up front, so the decoder has to grow its buffer as it goes. The Android side has to accept that, and it does, since sendrecv_v2_zstd is advertised by the device’s own adbd.
Upstream housekeeping: PR #849 closed as inactive
The same day, ya-webadb’s maintainer closed PR #849 — “use one-shot format in getDeviceFeatures for multi-device support” — with the note “Close as inactive”.
The PR is worth reading even though it was not merged, because it documents a genuine protocol subtlety in the ADB server.
The bug: AdbServerClient.getDeviceFeatures() fails with "more than one device/emulator" when more than one Android device is connected to the host running the ADB server. The reporter’s trace against a real ADB server (version 37.0.0, protocol 41) is the useful part:
1 2 3 4 5 6 7 8 9 10 11
# Two-step protocol (FAILS with multiple devices): Send: host:transport-id:270 → OKAY Send: host:features → FAIL "more than one device/emulator"
# One-shot format (WORKS): Send: host-transport-id:270:features → OKAY + features ✅ Send: host-serial:e986b63e:features → OKAY + features ✅
# Device-level after switch (WORKS - for reference): Send: host:transport-id:270 → OKAY Send: shell:echo hello → OKAY ✅
The explanation is that a transport switch sets the device context for device-level services (shell:, sync:, reverse:) but the ADB server does not inherit that context for host-level services like host:features.
But ya-webadb’s current implementation does not use the plain two-step form. It goes through createDeviceConnection(device, "host:features"), and the code carries a long comment explaining why:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// On paper, `host:features` is a host service (device features are cached in host), // so it shouldn't use `createDeviceConnection`, // which is used to forward the service to the device. // // However, `createDeviceConnection` is a two step process: // // 1. Send a switch device service to host, to switch the connection to the device. // 2. Send the actual service to host, let it forward the service to the device. // // In step 2, the host only forward the service to device if the service is unknown to host. // If the service is a host service, it's still handled by host. // // Even better, if the service needs a device selector, but the selector is not provided, // the service will be executed against the device selected by the switch device service. // So we can use all device selector formats for the host service, // and get the transport ID in the same time.
So ya-webadb’s two-step flow is intentional and correct: because the switch already selected a device, the unqualified host:features is executed against that device. This works — the maintainer verified it against two real devices on WSL2, Ubuntu 22.04, and Ubuntu 24.04, with output matching adb -s <serial> features exactly. The reporter’s issue was specific to an Ubuntu 22.04 environment that the maintainer could not reproduce, and the thread went inactive in July when the reporter stopped responding.
The maintainer’s stated preference frames the trade-off correctly: he would accept the PR’s workaround as a fallback rather than a replacement, since “the alternative might not return correct transport ID like the current method.” The proposed one-shot formatDeviceService() path does not return a transport ID, which getDeviceFeatures() needs to construct an AdbBanner and to key the transport. A fallback would therefore have to be a second code path with different return semantics — a reasonable thing to avoid in a library where the current path is correct in every reproducible environment.
For webadb.online this is a non-issue in practice: the project talks to adbd directly over WebUSB, not through an ADB server, so AdbServerClient is not on our path at all. It matters to Node.js-based tooling that connects to a local adb server with several devices attached.
What this means for webadb.online
The @yume-chan/pcm-player changes are the interesting part, and they change our roadmap rather than our code.
webadb.online currently ships thirteen panels — Terminal, File Manager, Logcat, App Manager, Screencast, Clipboard, Settings, and others — and none of them play audio from the device. Android’s scrcpy server supports audio forwarding (since scrcpy 2.0), and ya-webadb has a scrcpy client library, so an audio panel is a plausible next feature: connect a device, run the scrcpy server with --audio-source=output, forward the PCM stream, and play it.
Every one of the failure modes the 93fce383 commit addresses would have hit that panel on day one:
The muting-with-no-error problem. A device that produces audio perfectly well, a scrcpy server that forwards it correctly, and a panel that shows a live meter with moving numbers but produces no sound. With the old code, that is a genuinely hard bug to diagnose from the UI because nothing throws. With #tryResuming, the context recovers from a tab-switch suspend the next time a sample arrives.
The multi-player cost. A panel with an audio stream and a screencast running simultaneously is two audio contexts plus one video pipeline. Sharing the context keeps the audio rendering thread count at one.
The restart problem. A device disconnects and reconnects, and the panel wants to resume audio. With the old semantics the context was closed and a new one had to be constructed from scratch — including re-running the autoplay gesture check, which may no longer be satisfiable because the user’s original click was many seconds ago. With the shared pool, the reconnect reuses a context that has already been activated.
The zstd bump matters for a different panel: File Manager. Our pushBytes / pullBytes helpers go through the ADB sync service over WebUSB, and WebUSB bulk transfers are noticeably slower than they look. Compression is the lever. Right now webadb.online does not register a compression adapter at all — we are on @yume-chan/[email protected] and do not depend on @yume-chan/adb-compression-zstd — so every file transfer is uncompressed. Pulling in the zstd adapter is now a much smaller job than it was, because the codec’s streaming API is stable at 0.2.x and the simd128 build means the compression happens fast enough to keep up with the USB pipe.
Both of these are upstream-only changes this week — webadb.online’s own code did not change — but they remove two specific blockers from our backlog rather than adding generic capability. The next local commit that touches package.json should probably be the one that moves us from 3.0.0-beta.1 to 3.0.0-beta.4 and adds the zstd compression adapter.
The Chrome Direct Sockets check was skipped this week — that source is only scanned on Mondays — so this post covers upstream ya-webadb activity only.