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

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

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

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

What changed in the mirror’s per-feature page

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

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

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

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

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

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

What the underlying chromestatus JSON actually says

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

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

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

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

The channel calendar is unchanged too

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

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

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

Source-Specific Multicast went to Ready for Developer Testing

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

The relevant extract from the thread:

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

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

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

The chromestatus vs chromestatuslite divergence is now the actionable bit

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

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

What this means for webadb.online

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

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

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

Two independent adopters are running ya-webadb 2.6.4 in production

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

@yume-chan/adb-credential-web 2.1.0
@yume-chan/adb-daemon-webusb 2.3.2
@yume-chan/adb 2.6.4

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

The LineageOS infrastructure update from July 7
announced browser-based flashing as “something that had been requested for
many years”:

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:

1
2
3
4
5
6
7
8
9
10
function Hl() {
return new Promise((e, t) => {
let n = indexedDB.open(`Tango`, 1);
n.onerror = () => { t(n.error) };
n.onupgradeneeded = () => {
n.result.createObjectStore(`Authentication`, { autoIncrement: !0 })
};
n.onsuccess = () => { e(n.result) };
})
}

And the key generation:

1
2
3
4
5
6
7
8
9
async generateKey() {
let { privateKey: e } = await crypto.subtle.generateKey({
name: `RSASSA-PKCS1-v1_5`,
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: `SHA-1`
}, !0, [`sign`, `verify`]);
// ...
}

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:

1
2
3
const request = indexedDB.open("Tango", 1);
// ...
db.createObjectStore("Authentication", { autoIncrement: true });

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.

Source-Specific Multicast for Direct Sockets ships in Chrome 158

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                          Oct 12                     Oct 24
updated.when: 2026-02-21 02:52:29 2026-10-22 20:05:35
updated.by: (unchanged) [email protected]
accurate_as_of: 2026-02-21 02:52:29 2026-10-22 20:05:35
is_released: false true
active_stage_id: (160 stage) 5117210058162176
stage 160 desktop_first: null 158
stage 130 desktop_first: null 152
intent_stage: "None" "None"
flag_name: (absent) source-specific-multicast-in-direct-sockets
finch_name: (absent) SourceSpecificMulticastInDirectSockets
spec_link: WICG/direct-sockets/pull/81 wicg.github.io/direct-sockets/#dom-multicastgroupoptions-sourceaddress
api_spec: (unset) true
shipping_year: 2026 2026
first_enterprise_notification_milestone: 143
enterprise_impact: (medium-ish) 1
bug_url: issues.chromium.org/issues/461262401 (unchanged)

Three of those lines are doing a lot of work.

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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-03-12 5749767220104c43be19694ebcc7e756850bde03
refs/heads/main@{#1598159}

2026-03-18 7cfd0862612bfcb89db88414860249221b704ee2
refs/heads/main@{#1601525}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2026-10-19 13:31:00  a559af362c7f4b9fb27e41efdf73b5d15ea30401
"[Direct Sockets] Reject non-multicast groups in RestrictedUDPSocket"
crrev.com/c/8583003 refs/heads/main@{#1721842}

2026-10-19 14:02:10 a9a57df5bd3e82ee75d927c0a87ba8dbac44b2d9
"Reject invalid group addresses in network::UDPSocket"
crrev.com/c/8583123 refs/heads/main@{#1721866}

2026-10-20 10:42:54 ca9cb7f5721d5e1e485c2c622878f2783479f992
"[Direct Sockets] Reject loopback sourceAddress in MulticastController"
crrev.com/c/8582708 refs/heads/main@{#1722769}

2026-10-20 (rolled) 59141ddb29dde8d8e8f34f331d7e15efe1daee04
devtools-frontend: "[network] Key Direct Sockets multicast
memberships by group and source"
crrev.com/c/8586159

2026-10-20 13:19:36 6408d75ec58d666f6e045bd8cda5fef846d423ef
"[Direct Sockets] Count SSM joins separately from ASM joins"
crrev.com/c/8574609 refs/heads/main@{#1722854}

2026-10-20 13:24:40 c0b84c898438c3fea8cb8a5af3ee807864c5b8f4
"[Direct Sockets] Report the SSM source separately in CDP events"
crrev.com/c/8588617 refs/heads/main@{#1722855}

2026-10-21 23:02:15 8774e07525eb94b789d22da15451a772af708986
"[Direct Sockets] Ship SourceSpecificMulticastInDirectSockets"
crrev.com/c/8581753 refs/heads/main@{#1724238}

The last one is the ship commit, and it is 24 hours and change old:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[Direct Sockets] Ship SourceSpecificMulticastInDirectSockets

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.

Chromestatus: https://chromestatus.com/feature/6208452397498368
I2S: https://groups.google.com/a/chromium.org/d/msgid/blink-dev/6ad03dc5.32cf16a2.aa5e8.002a.GAE%40google.com

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:

1
2
3
4
5
6
7
8
9
blink_unittests:      MulticastControllerSsmTest.*
(validation, membership rules, IP-version mismatch,
ASM/SSM mixing)
content_browsertests: DirectSocketsBoundUdpBrowserTest.*SSM*
browser_tests (IWA): ChromeDirectSocketsUdpIsolatedWebAppMulticastTest.*SSM*
net_unittests: UDPSocketTest.JoinSourceGroup*, LeaveSourceGroup*,
SSMSourceFilteringMultiNIC*
services_unittests: bad-message / invalid-address tests
(crrev.com/c/8583003, crrev.com/c/8583123)

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:

1
2
3
4
5
6
7
updated.when:         2026-06-22 13:32:10.563438   (unchanged since Jun 22)
updated.by: [email protected]
is_released: false
accurate_as_of: 2026-06-22 13:32:10.563438
intent_stage: "None"
active_stage_id: 5101390021132288 (stage_type 160, desktop_first: 151)
shipping_year: 2026

And the mirror’s rendering of it, side by side with the SSM row on the
same site:

1
2
/feature/6208452397498368   Status: Enabled by default (Chrome 158)
/feature/6046077976444928 Status: Proposed (Chrome Proposed)

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 today and SSM is on the 158 branch

Chrome 158 branches off main today, 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:

1
2
3
4
channel  mstone  branch_point    earliest_beta  early_stable  final_beta  stable_date
stable 157 2026-10-12 2026-10-14 2026-10-21 2026-10-21 2026-11-03
beta 158 2026-10-26 2026-10-28 2026-11-04 2026-11-04 2026-11-17
dev 159 2026-11-09 2026-11-11 2026-11-18 2026-11-18 2026-12-08

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.

Five days of margin

Here is the whole timeline of the feature, from the commit stream on the
tracking bug
(issues.chromium.org/issues/461262401)
and the chromestatus row
(6208452397498368):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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
export interface DirectUDPSocketJoinedMulticastGroupEvent {
/** 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:

1
2
3
4
5
6
7
8
9
updated.when:         2026-06-22 13:32:10.563438
updated.by: [email protected]
is_released: false
accurate_as_of: 2026-06-22 13:32:10.563438
active_stage_id: 5101390021132288 (stage_type 160, desktop_first: 151)
intent_stage: None
shipping_year: 2026
flag_name: None
finch_name: None

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”):

1
2
3
4
5
6
7
8
--- a/pdl/domains/Page.pdl
+++ b/pdl/domains/Page.pdl
@@ -117,7 +117,6 @@ domain Page
digital-credentials-get
direct-sockets
direct-sockets-multicast
- direct-sockets-private
display-capture

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:

1
2
3
4
5
DirectSocketsServiceImpl::OnResolveCompleteForTCPSocket(...)
-> RequestPrivateNetworkAccessAndCreateSocket(...)

DirectSocketsServiceImpl::OpenBoundUDPSocket(...)
-> RequestPrivateNetworkAccessAndCreateSocket(...)

while the server path goes straight to the socket:

1
2
DirectSocketsServiceImpl::OpenTCPServerSocket(...)
-> network_context_->CreateTCPServerSocket(options->local_addr, ...)

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.

ya-webadb PR #875 runs the scrcpy server as shell under root adbd

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:

1
2
3
4
5
6
7
8
9
10
const client = await AdbScrcpyClient.start(
adb,
DefaultServerPath,
new AdbScrcpyOptions3_3_3({ audio: false, control: true }),
);
await client.controller.setClipboard({
sequence: 0n,
paste: true,
content: "hello 中文🙂",
});

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const args = [
// Use `CLASSPATH=` as `-cp` argument requires Android 8.0
`CLASSPATH=${path}`,
"app_process",
/* unused */ "/",
"com.genymobile.scrcpy.Server",
options.version,
...options.serialize(),
];

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 ]; then exec su 2000 sh -c '<command>'; else exec 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);
return true;
});
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 original args 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:

1
2
3
4
5
6
const command = escapeArg(
[
`CLASSPATH=${escapeArg(path)}`,
...args.slice(1).map(escapeArg),
].join(" "),
);

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
await writeFile(
join(directory, "id"),
`#!/bin/sh\nprintf '%s\\n' '${uid}'\n`,
{ mode: 0o755 },
);
await writeFile(
join(directory, "su"),
'#!/bin/sh\nprintf \'%s\\n\' "$1" > "$SCRCPY_TEST_SU_LOG"\nshift\nexec "$@"\n',
{ mode: 0o755 },
);
await writeFile(
join(directory, "app_process"),
'#!/bin/sh\nprintf \'%s\\n\' "$CLASSPATH" "$@"\n',
{ mode: 0o755 },
);

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const spawn = t.mock.fn((command: string | readonly string[]) => {
output = execFileSync(
"sh",
["-c", typeof command === "string" ? command : command.join(" ")],
{
encoding: "utf8",
env: {
...process.env,
PATH: `${directory}:${process.env["PATH"]}`,
SCRCPY_TEST_SU_LOG: suLog,
},
},
);
return Promise.resolve(serverProcess);
});

And then the adversarial case, which is the one that matters:

1
2
3
4
5
6
const marker = join(device.directory, "injected");
const special = `space 'quoted' "$(touch ${marker})"; value`;
const path = `/data/local/tmp/${special}.jar`;
const client = await device.start(undefined, path, {
videoEncoder: special,
});

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:

1
await assert.rejects(access(marker), { code: "ENOENT" });

and that the server received the special string as one literal argument:

1
2
3
4
5
6
7
assert.deepEqual(device.output().trimEnd().split("\n"), [
path,
"/",
"com.genymobile.scrcpy.Server",
client.options.version,
...client.options.serialize(),
]);

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function escapeArg(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.

ya-webadb lands inject-key shorthands — a one-line keypress becomes Down, hold, Up

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:

1
2
3
injectKeyCode(message: Omit<ScrcpyInjectKeyCodeControlMessage, "type">) {
return this.write(this.#serializer.injectKeyCode(message));
}

ScrcpyInjectKeyCodeControlMessage is a struct, defined in
libraries/scrcpy/src/control/inject-key-code.ts:

1
2
3
4
5
6
7
8
9
10
11
export const ScrcpyInjectKeyCodeControlMessage = 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:

1
2
3
4
export const AndroidKeyEventAction = {
Down: 0,
Up: 1,
} as const;

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:

1
2
3
4
5
6
await controller.write.injectKeyCode({
action: AndroidKeyEventAction.Down,
keyCode: AndroidKeyCode.Enter,
repeat: 0,
metaState: AndroidKeyEventMeta.None,
});

…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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
injectKeyDown(
message: Omit<ScrcpyInjectKeyCodeControlMessage, "type" | "action">,
) {
return this.injectKeyCode({
...message,
action: AndroidKeyEventAction.Down,
});
}

injectKeyUp(
message: Omit<ScrcpyInjectKeyCodeControlMessage, "type" | "action">,
) {
return this.injectKeyCode({
...message,
action: AndroidKeyEventAction.Up,
});
}

async injectKeyPress(
message: Omit<ScrcpyInjectKeyCodeControlMessage, "type" | "action">,
hold: number = 100,
) {
await this.injectKeyDown(message);
await delay(hold);
await this.injectKeyUp(message);
}

Three design decisions are worth pulling out.

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:

1
2
3
4
5
export function delay(time) {
return new Promise(resolve => {
globalThis.setTimeout(() => resolve(), time);
});
}

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:

1
2
3
4
5
6
7
libraries/scrcpy/src/1_15/impl/control-message-types.ts:5:    [ScrcpyControlMessageType.InjectKeyCode]: 0,
libraries/scrcpy/src/1_18/impl/control-message-types.ts:5: [ScrcpyControlMessageType.InjectKeyCode]: 0,
libraries/scrcpy/src/2_4/impl/control-message-types.ts:5: [ScrcpyControlMessageType.InjectKeyCode]: 0,
libraries/scrcpy/src/2_7/impl/control-message-types.ts:5: [ScrcpyControlMessageType.InjectKeyCode]: 0,
libraries/scrcpy/src/3_0/impl/control-message-types.ts:5: [ScrcpyControlMessageType.InjectKeyCode]: 0,
libraries/scrcpy/src/4_0/impl/control-message-types.ts:5: [ScrcpyControlMessageType.InjectKeyCode]: 0,
libraries/scrcpy/src/4_1/impl/control-message-types.ts:5: [ScrcpyControlMessageType.InjectKeyCode]: 0,

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:

1
2
3
4
5
6
7
8
9
export function serializeBackOrScreenOnControlMessage(
message: ScrcpyBackOrScreenOnControlMessage,
) {
if (message.action === AndroidKeyEventAction.Down) {
return BackOrScreenOnControlMessage.serialize(message);
}

return undefined;
}

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:

1
2
3
4
export const BackOrScreenOnControlMessage = struct(
{ action: u8<AndroidKeyEventAction>() },
...
);

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:

1
2
3
$ git diff --stat v3.0.0-beta.4 origin/main
libraries/scrcpy/src/control/writer.ts | 30 +++++++++++++++++++++++++++++-
1 file changed, 29 insertions(+), 1 deletion(-)

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
why hold 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.

PR #875 is withdrawn — scrcpy 4.0 already fixed the root-clipboard crash, so ya-webadb is not shipping a `su 2000` shim

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")
private static void dropRootPrivileges() {
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();

Options options = Options.parse(args);

That placement matters. The UID drop happens before Options.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.

Fixes #6224 <https://github.com/Genymobile/scrcpy/issues/6224>

Suggested-by: Simon Chan <[email protected]>

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
inside setPrimaryClip, 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:

Tried this idea.
https://github.com/Genymobile/scrcpy/compare/dev...yume-chan:feat/patch-system-context?expand=1

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 upstream issue has been fixed in 4.0:
https://github.com/Genymobile/scrcpy/issues/6224#issuecomment-3694901268

Why the library patch was rejected

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:

Does it work on real rooted device? (maybe yes because you used
su 2000 sh -c?) https://github.com/Genymobile/scrcpy/pull/4127#issuecomment-1615943436

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:

1
2
3
4
5
6
7
8
9
10
11
export class AdbScrcpyOptions4_0<
TInit extends ScrcpyOptions4_0.Init = ScrcpyOptions4_0.Init,
> extends ScrcpyOptions4_0<TInit> implements AdbScrcpyOptions<...> {
readonly version: string;
readonly spawner: AdbNoneProtocolSpawner | undefined;

constructor(init: TInit, clientOptions?: AdbScrcpyClientOptions) {
super(init);
this.version = clientOptions?.version ?? "4.0";
this.spawner = clientOptions?.spawner;
}

and libraries/scrcpy/src/latest.ts points at 4.1:

1
export { ScrcpyOptions4_1 as ScrcpyOptionsLatest } from "./4_1/index.js";

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:

1
2
3
4
5
6
7
static async pushServer(
adb: Adb,
file: ReadableStream<MaybeConsumable<Uint8Array>>,
path = DefaultServerPath,
) {
await adb.sync.write({ path, readable: file });
}

and start builds the argv from options.version:

1
2
3
4
5
6
7
8
9
const args = [
// Use `CLASSPATH=` as `-cp` argument requires Android 8.0
`CLASSPATH=${path}`,
"app_process",
/* unused */ "/",
"com.genymobile.scrcpy.Server",
options.version,
...options.serialize(),
];

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:

1
2
3
4
5
6
7
8
9
10
public static Options parse(String... args) {
if (args.length < 1) {
throw new IllegalArgumentException("Missing client version");
}

String clientVersion = args[0];
if (!clientVersion.equals(BuildConfig.VERSION_NAME)) {
throw new IllegalArgumentException(
"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:

1
2
3
4
5
6
const streams = await Promise.race([
process.exited.then(() => {
throw new AdbScrcpyExitedError(lines);
}),
connection.getStreams(),
]);

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.

Source-Specific Multicast got three LGTMs — and the spec disagrees with Chrome

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 intent thread and its three LGTMs

The intent to ship for Source-Specific Multicast (SSM) was posted to blink-dev
on 2026-10-14 at 19:43 PDT, generated by Chrome Platform Status on behalf of
the feature owner. The thread is
Intent to Ship: Source Specific Multicast for Direct Sockets API,
and the feature’s chromestatus entry is
6208452397498368.

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.

2026-10-20, 06:29 PDT — Omar Ramadan (feature owner, Blockcast):

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:

  1. 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:

  1. 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”).
  2. 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.
  3. Both methods check for a closed socket first, before validating their
    arguments.
  4. leaveGroup() throws an InvalidStateError while the join is still
    pending, because [[joinedGroups]] only holds completed joins.
  5. 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 = new UDPSocket({ 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;

await multicastController.leaveGroup("239.0.0.1", {
sourceAddress: "192.168.1.50",
});

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:

  1. 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.
  2. 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.
  3. 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)
2025-10-09 Multicast added to the spec (WICG#79)
2025-11-19 SSM chromestatus entry created (6208452397498368)
2025-11-26 SSM added to the spec (WICG#81)
2025-12-01 Intent to Prototype posted to blink-dev
2026-02-21 SSM added to the explainer (WICG#83)
2026-02-21 SSM lands in Chromium (main@{1597329})
2026-07-10 Sending to a multicast address requires the permission policy (WICG#85)
2026-09-12 Chromium commit 3076e5f5 reverts the SSM implementation over a flaky macOS test
2026-10-08 SSM relands and is cherry-picked to the 158 branch
2026-10-14 Intent to Ship posted; WICG#86 opened
2026-10-19 Reilly Grant: IWA OWNER LGTM. Alex Russell asks “is anyone asking for this?”
2026-10-20 Omar Ramadan answers; SSM implementation is in the tree for 158
2026-10-21 Alex Russell LGTM1, Chris Harrelson LGTM2, Mike Taylor LGTM3
2026-10-21 Chrome 158 branches
2026-10-27 chromestatus entry 6208452397498368 last updated
2026-10-30 This post

References

Chromium plumbs permissions policy into shared workers

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:

1
2
3
4
"status": { "text": "Proposed", "val": 2, "milestone_str": "Proposed" },
"desktop": 151,
"origintrial": false,
"announced": true

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:

1
2
3
4
5
6
7
Bug: 492476083
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7845254
Reviewed-by: Vlad Krot <[email protected]>
Reviewed-by: Danil Somsikov <[email protected]>
Reviewed-by: Nasko Oskov <[email protected]>
Reviewed-by: Andrew Rayskiy <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1646037}

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:

1
2
3
4
5
6
7
8
9
message IwaAccessControl {
CONTROLLED_FRAME = 1;
DIRECT_SOCKETS = 2;
DIRECT_SOCKETS_MULTICAST = 3;
reserved 4;
SMART_CARD = 5;
SUB_APPS = 6;
UNRESTRICTED_WEBUSB = 7;
}

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:

1
2
3
4
5
6
7
8
constexpr std::string_view kEntitledFeatures[] = {
"direct-sockets", "direct-sockets-multicast",
"sub-apps", "usb-unrestricted",
"web-printing", "controlled-frame",
#if defined(ENABLE_SMART_CARD)
"smart-card",
#endif
};

"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;
}

rfh->GetBrowserContext()
->GetPermissionController()
->RequestPermissionsFromCurrentDocument(...)

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:

1
2
3
.WithPermissionsPolicy("direct-sockets", "(self)")
.WithPermissionsPolicy("direct-sockets-private", "(self)")
.WithPermissionsPolicy("direct-sockets-multicast", "(self)")

After:

1
2
3
4
.WithPermissionsPolicy("direct-sockets", "(self)")
.WithPermissionsPolicy("local-network", "(self)")
.WithPermissionsPolicy("loopback-network", "(self)")
.WithPermissionsPolicy("direct-sockets-multicast", "(self)")

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:

1
2
3
4
5
6
7
8
9
10
11
12
-class IsolatedWebAppDirectSocketsPrivatePermissionsPolicyUnpackingTest
- : public web_app::IsolatedWebAppBrowserTestHarness {
- public:
- content::RenderFrameHost* InstallAndOpenIWAWithFeatures(
- std::map<network::mojom::PermissionsPolicyFeature,
- std::vector<url::Origin>> feature_map = {}) {
- auto builder =
- ManifestBuilder(
- /*include_cross_origin_isolated_permissions_policy=*/true)
- .AddPermissionsPolicyWildcard(
- network::mojom::PermissionsPolicyFeature::kDirectSockets);
...

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…

Its Chrome status block:

1
2
3
"status": { "text": "Proposed", "val": 2, "milestone_str": "Proposed" },
"desktop": null,
"announced": false

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:

Commit Date Subject
8c2c2dfb5fb2 2026-09-30 Add feature and define allow parameter
c40fd2ef2dec 2026-10-20 Parse SharedWorkerOptions.allow
3aa2f4ff7f9e 2026-10-20 Check allow against creator
c40dcca70c04 2026-10-20 Plumb to shared worker host
1c9716f07802 2026-10-21 Send down to SharedWorkerGlobalScope
e8113630b3fd 2026-10-28 Ensure consistency of feature flags

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

Three files touched:

1
2
3
content/public/common/content_switch_dependent_feature_overrides.cc (+3/-0)
third_party/blink/renderer/core/workers/shared_worker.idl (+1/-0)
third_party/blink/renderer/platform/runtime_enabled_features.json5 (+4/-0)

The shared_worker.idl change is the whole API surface of this CL — one
optional DOMString allow member on the SharedWorkerOptions dictionary:

1
2
3
4
5
dictionary SharedWorkerOptions {
DOMString name;
DOMString type;
sequence<USVString> credentials;
};

becomes:

1
2
3
4
5
6
dictionary SharedWorkerOptions {
DOMString name;
DOMString type;
sequence<USVString> credentials;
DOMString allow;
};

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.

CL 2 — parsing the attribute

c40fd2ef2dec
wires the parser in:

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.

The parser gains two new entry points:

1
2
3
4
5
6
7
third_party/blink/renderer/core/permissions_policy/permissions_policy_parser.cc (+29/-0)
third_party/blink/renderer/core/permissions_policy/permissions_policy_parser.h (+10/-0)
third_party/blink/renderer/core/permissions_policy/permissions_policy_test.cc (+61/-0)
third_party/blink/renderer/core/workers/build.gni (+1/-0)
third_party/blink/renderer/core/workers/shared_worker.cc (+35/-0)
third_party/blink/renderer/core/workers/shared_worker.h (+8/-0)
third_party/blink/renderer/core/workers/shared_worker_test.cc (+68/-0)

CL 3 — the delegation check

3aa2f4ff7f9e
is the security-relevant one:

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:

1
2
3
4
5
6
7
services/network/public/cpp/permissions_policy/permissions_policy.cc (+10/-0)
services/network/public/cpp/permissions_policy/permissions_policy.h (+12/-0)
services/network/public/cpp/permissions_policy/permissions_policy_unittest.cc (+66/-0)
third_party/blink/renderer/core/permissions_policy/permissions_policy_parser.cc (+2/-8)
third_party/blink/renderer/core/workers/shared_worker.cc (+38/-5)
third_party/blink/renderer/core/workers/shared_worker.h (+7/-3)
third_party/blink/renderer/core/workers/shared_worker_test.cc (+162/-10)

CL 4 — across the process boundary

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.

This is the CL that adds the mojom plumbing:

1
2
3
4
5
6
third_party/blink/public/mojom/worker/shared_worker_info.mojom (+5/-0)
services/network/public/mojom/permissions_policy/permissions_policy.mojom (+6/-0)
services/network/public/mojom/permissions_policy/permissions_policy_mojom_traits.cc (+8/-0)
services/network/public/mojom/permissions_policy/permissions_policy_mojom_traits.h (+15/-0)
content/browser/worker_host/shared_worker_service_impl.cc (+25/-0)
content/browser/worker_host/shared_worker_host.cc (+5/-1)

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:

1
2
3
4
5
6
7
8
9
// static
std::unique_ptr<PermissionsPolicy> PermissionsPolicy::CreateFromInheritedPolicy(
const network::PermissionsPolicyFeaturesBitset& inherited_policies,
const network::ParsedPermissionsPolicy& header_policy,
const url::Origin& origin) {
return base::WrapUnique(new PermissionsPolicy(
origin, CreateAllowlistsAndReportingEndpoints(header_policy),
inherited_policies, network::GetPermissionsPolicyFeatureList(origin)));
}

Second, the TODO is honest about a gap: the Permissions-Policy response
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:

1
2
3
4
5
{
name: "PermissionsPolicyForSharedWorkers",
status: "experimental",
public: true,
},

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:

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "direct-sockets-in-service-workers",
"owners": [ "//content/browser/direct_sockets/OWNERS" ],
- "expiry_milestone": 150
+ "expiry_milestone": 170
},
{
"name": "direct-sockets-in-shared-workers",
"owners": [ "//content/browser/direct_sockets/OWNERS" ],
- "expiry_milestone": 150
+ "expiry_milestone": 170
},

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”
  • 3f3a3797878f (2025-02-06) — “DirectSockets: Add Shared/Service workers to .idl defs behind flags”
  • b4d6430bfe1d (2025-04-07) — “[DirectSockets] Relax the CrossOriginCapability requirement for workers”
  • 2bba61b431f3 (2026-04-01) — “[Direct Sockets] Implement dynamic enablement in Isolated Contexts”

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:

1
2
3
4
const worker = new SharedWorker("worker.js", {
name: "adb",
allow: "direct-sockets local-network loopback-network",
});

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.

ya-webadb shares AudioContexts, and zstd crosses into SIMD

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:

1
2
3
4
5
6
7
constructor(sampleRate: number, channelCount: number) {
this.#context = new AudioContext({
latencyHint: "interactive",
sampleRate,
});
this.#channelCount = channelCount;
}

Every player instance allocated its own AudioContext, and stop() closed
it:

1
2
3
4
5
6
7
8
9
10
11
async stop() {
if (this.#stopped) {
return;
}
this.#stopped = true;

this.#worklet?.disconnect();
this.#worklet = undefined;

await this.#context.close();
}

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(new Int16Array([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
new AudioContext, which meant paying the full construction cost again.

The commit changes this in three coordinated moves.

Move one: a context pool keyed by sample rate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const SharedContexts = new Map<number, AudioContext>();

function getSharedContext(sampleRate: number) {
if (!SharedContexts.has(sampleRate)) {
SharedContexts.set(
sampleRate,
new AudioContext({
latencyHint: "interactive",
sampleRate,
}),
);
}
return SharedContexts.get(sampleRate)!;
}

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:

1
2
3
4
5
6
7
8
constructor(
sampleRate: number,
channelCount: number,
context?: AudioContext,
) {
this.#context = context ?? getSharedContext(sampleRate);
this.#channelCount = channelCount;
}

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:

1
2
3
4
5
this.#worklet = new AudioWorkletNode(this.#context, this.sourceName, {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [this.#channelCount],
});

The context is the clock and the rendering thread; the node is the channel
configuration. That separation is what makes the pool sound.

Move two: memoized worklet module loading

1
2
3
4
5
6
7
8
9
10
11
12
13
const AddModulePromises = new WeakMap<AudioContext, Promise<void>>();

function addWorkletModule(context: AudioContext) {
if (!AddModulePromises.has(context)) {
AddModulePromises.set(
context,
context.audioWorklet.addModule(
new URL("./worker.js", import.meta.url),
),
);
}
return AddModulePromises.get(context)!;
}

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;

this.#worklet?.disconnect();
this.#worklet = undefined;

this.#buffers.length = 0;

// 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
) {
void this.#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:

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

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

  3. 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) {
throw new Error("PcmPlayer is stopped");
}

if (this.#worklet === undefined) {
this.#buffers.push(source);
return;
}

this.#tryResuming();
this.feedCore(this.#worklet, source);
}

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 of this.#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:

1
2
3
4
5
6
if (!this.#speedUp && this.#totalSampleCount > 0.25 * 48000) {
this.#speedUp = true;
this.#readOffset = 0;
this.#inputOffset = 0;
this.#outputOffset = 0;
}

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:

1
2
3
4
5
6
7
8
async start() {
await addWorkletModule(this.#context);

if (this.#stopped) {
return;
}
...
}

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:

1
2
3
     "dependencies": {
- "@structured-world/structured-zstd": "^0.0.58",
+ "@structured-world/structured-zstd": "^0.2.6",

The upstream package’s publish timeline makes the significance clear:
0.0.58 (2026-10-01) → 0.0.59 (10-08) → 0.1.0 (10-10) → 0.1.1
(10-10) → 0.2.0 (10-12) → 0.2.1 (10-16) → 0.2.2 (10-17) → 0.2.3
(10-19) → 0.2.4 (10-21) → 0.2.5 (10-22) → 0.2.6 (10-26).

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:

1
2
3
shell_v2,cmd,stat_v2,ls_v2,fixed_push_mkdir,apex,abb,fixed_push_symlink_timestamp,
abb_exec,remount_shell,track_app,sendrecv_v2,sendrecv_v2_brotli,
sendrecv_v2_lz4,sendrecv_v2_zstd,sendrecv_v2_dry_run_send,openscreen_mdns

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-detect simd()): 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”:

1
2
3
4
5
6
7
8
export function registerZstdCompression(
options: {
worker?: "auto" | boolean | undefined;
compressionLevel?: number | undefined;
} = {},
) {
AdbSync.Compression.registerCompressionAdapter(
...

The worker wrapper uses Comlink for the proxy and terminates the worker on
error so a failed compression attempt does not leave a dangling thread:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function cleanupOnError<T>(worker: Worker | undefined, callback: () => T): T {
if (!worker) {
return callback();
}

try {
const result = callback();
if (result instanceof Promise) {
return result.catch((e) => {
worker.terminate();
throw e;
}) as T;
}
return result;
} catch (e) {
worker.terminate();
throw e;
}
}

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 = await createCompressStream(19);
const out: Uint8Array[] = [];
for await (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.