ya-webadb's Consumable writable path leaks an unhandled rejection

On 2026-11-02, Qingyu Wang (colinaaa) filed
yume-chan/ya-webadb#876
with a minimal reproduction that crashes Node.js even when the caller wraps
the write in try / catch. The bug lives in
@yume-chan/stream-extra‘s ConsumableWritableStream.write — the exact
helper that AdbDaemonDispatcher.sendPacket and the ADB auth handshake call
on every single packet. By the end of the day the fix had been proposed,
reviewed, rejected, and re-proposed; the surviving version is
#878, currently open, one
line of production code and 67 lines of regression test.

What makes this worth writing about is not the bug. It is the shape of the
review that killed the first fix — because the “obvious” repair, the one the
issue author proposed and most reviewers would have approved, silently
breaks error precedence in a way that is invisible until you read the diff
twice.

The bug: a promise with no handler attached before control flow exits

Consumable is ya-webadb’s backpressure primitive. It wraps a value in an
object carrying a consumed promise; the producer resolves or rejects that
promise when the downstream sink actually finishes handling the chunk. The
whole point is to let a writer know that its bytes were really consumed
rather than merely buffered.

ConsumableWritableStream is the writable half. Its write sink delegates
to chunk.tryConsume:

1
2
3
4
5
write(chunk, controller) {
return chunk.tryConsume((chunk) =>
sink.write?.(chunk, controller),
);
}

and Consumable.prototype.tryConsume in
libraries/stream-extra/src/consumable.ts does this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
tryConsume<U>(callback: (value: T) => U) {
try {
let result = this.#task.run(() => callback(this.value));
if (isPromiseLike(result)) {
result = result.then(
(value) => {
this.#resolver.resolve();
return value;
},
(e) => {
this.#resolver.reject(e);
throw e;
},
) as U;
} else {
this.#resolver.resolve();
}
return result;
} catch (e) {
this.#resolver.reject(e);
throw e;
}
}

Read that rejection branch carefully: when the sink’s write callback
rejects, tryConsume does two things with the same error e. It calls
this.#resolver.reject(e), which rejects the consumed promise hanging off
the Consumable. And then it rethrows e, which rejects the promise
returned by writer.write(consumable).

Two rejections, one error. That is deliberate — the sink failed, so both the
“did you consume it” signal and the “did the write succeed” signal must
reject — but it creates the hazard.

The static helper that most callers actually use was, on main as of
59e95a0e, this:

1
2
3
4
5
6
7
8
static async write<T>(
writer: WritableStreamDefaultWriter<Consumable<T>>,
value: T,
) {
const consumable = new Consumable(value);
await writer.write(consumable);
await consumable.consumed;
}

Note the ordering. await writer.write(consumable) comes first. If the sink
rejects, that await throws, and control flow leaves the function before
await consumable.consumed is ever reached. The consumed promise was
rejected by tryConsume at that same moment, but nobody has attached a
rejection handler to it. In a browser that means a console warning. In
Node.js, where an unhandled rejection terminates the process by default, it
means the process dies — even though the caller did exactly what they were
supposed to:

1
2
3
4
5
6
try {
await Consumable.WritableStream.write(writer, new Uint8Array([1, 2, 3]));
} catch (e) {
console.log("Caught expected write error:", e.message);
}
// Node.js still crashes with UnhandledPromiseRejection

The irony is sharp: the try / catch around the call is what makes the code
look correct, and it is precisely the catch that means the caller never
sees the second rejection. The catch handles one of the two rejections and
leaves the other to the runtime.

Why this matters for ADB specifically

Consumable.WritableStream.write is not an exotic corner of the library.
It is on the hot path for every ADB packet:

  • libraries/adb/src/daemon/dispatcher.ts:458 — sendPacket, which every
    command (shell, sync, reverse, forward, …) funnels through.
  • libraries/adb/src/daemon/auth/authenticator.ts:62 — sendPacket during
    the authentication handshake.
  • libraries/adb/src/service/sync/socket.ts:116 — the sync service.

So the trigger condition is not exotic either: the device goes away
mid-write
. A phone that sleeps, a USB cable that gets nudged, a wireless
debugging session that times out, an adb kill-server racing an in-flight
command. In every one of those cases the socket write fails, the sink
rejects, and tryConsume rejects both consumed and writer.write.

The issue text names the scrcpy case explicitly:

any write error on a Consumable.WritableStream or
MaybeConsumable.WritableStream (for example, writing through
ScrcpyControlMessageWriter after the underlying socket has been closed by
the remote peer) crashes the process with unhandledRejection even if the
caller wraps write() in try / catch or attaches .catch().

The first fix, and why it was wrong

The issue author proposed what looks like the textbook repair: await both
promises concurrently, so both rejection handlers get attached in the same
tick.

1
2
3
4
5
6
7
static async write<T>(
writer: WritableStreamDefaultWriter<Consumable<T>>,
value: T,
) {
const consumable = new Consumable(value);
await Promise.all([writer.write(consumable), consumable.consumed]);
}

That is PR #877, opened
2026-11-02 10:56 UTC with commit 79424b60.

It does fix the unhandled rejection. Promise.all attaches handlers to both
promises synchronously, so neither rejection is ever orphaned. But it breaks
something else, and CodeRabbit’s review bot caught it on the inline comment
at libraries/stream-extra/src/consumable/writable.ts:29:

Preserve write completion and error precedence. Update the helper
containing writer.write and consumable.consumed to attach a rejection
handler to consumable.consumed immediately, then await writer.write
before awaiting consumable.consumed. Preserve write completion and ensure
a write rejection takes precedence over an earlier consumed rejection.

The problem with Promise.all is that it resolves on first rejection but
attaches handlers to both, so the error that surfaces to the caller is
whichever promise rejected first in time — not necessarily the error the
caller should care about.

Concretely: tryConsume rejects consumed with the sink error and then
rethrows. But a Consumable can also be errored from the other direction —
chunk.error(new Error(...)) is a public method on the class. A sink that
does:

1
2
3
4
5
async write(chunk) {
chunk.error(new Error("consumed error"));
await new Promise((resolve) => setImmediate(resolve));
throw new Error("writer error");
}

rejects consumed with "consumed error" first, and rejects
writer.write with "writer error" a tick later. Under Promise.all, the
caller sees "consumed error" — the earlier rejection — and never learns
that the write itself failed. The write error is swallowed by the
already-settled Promise.all.

That is a real behavioural regression: the error the caller receives no
longer describes the failure that actually stopped the write. The
sequential await writer.write(...) → await consumable.consumed ordering
guarantees the write error wins, because the write error is the one that
causes consumed to reject in the first place.

The author closed #877 at 11:44 UTC with:

Superseded by #878 (which addresses the review comment by attaching a
rejection handler to consumable.consumed while preserving sequential
await writer.write(consumable) and await consumable.consumed).

and opened #878 fifty seconds later.

The fix that survived

PR #878, commit
3e84659c, is the whole of it:

1
2
3
4
5
6
     const consumable = new Consumable(value);
+ // Suppress unhandled-rejection when `writer.write` rejects
+ // before `consumable.consumed` is awaited
+ void consumable.consumed.catch(() => {});
await writer.write(consumable);
await consumable.consumed;

Three lines, and the order of the two awaits is untouched.

The trick is that void consumable.consumed.catch(() => {}) attaches a
rejection handler to consumed immediately, at construction time,
without changing when the function actually awaits it. The .catch() returns
a new promise (which resolves to undefined and is discarded via void);
the original consumed promise is untouched and still rejects as before.
What changes is only that the runtime now considers it handled.

Meanwhile the sequential awaits stay sequential, so:

  • if writer.write rejects → the caller sees the write error, and
    consumed‘s rejection has already been marked handled;
  • if writer.write resolves but consumed rejects → the caller sees the
    consumed error, which is the only error there is.

Both error-precedence rules from the original implementation are intact. The
only thing removed is the orphan.

The regression tests

The 67 added lines in libraries/stream-extra/src/consumable.spec.ts are
worth reading, because they encode the invariants that the Promise.all
version violated. The first test is the direct regression:

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
it("WritableStream.write should not emit unhandledRejection when sink write rejects", async () => {
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
try {
const stream = new Consumable.WritableStream<Uint8Array>({
async write() {
await Promise.resolve();
throw new Error("sink write failed");
},
});
const writer = stream.getWriter();

await assert.rejects(
Consumable.WritableStream.write(writer, new Uint8Array([1, 2, 3])),
/sink write failed/,
);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepStrictEqual(unhandled, []);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});

The await new Promise((resolve) => setImmediate(resolve)) before the final
assertion is the load-bearing part. Node.js emits unhandledRejection on
the next macrotask after a promise is rejected with no handler, not
synchronously. Without the setImmediate turn, the test would pass against
the broken implementation too — the assertion would run before the runtime
had a chance to complain.

The second test is the one that the Promise.all version fails. It pins
error precedence explicitly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
it("WritableStream.write should wait for writer.write and prefer its rejection over consumed rejection", async () => {
// ...
let writeFinished = false;
const stream = new WritableStream<Consumable<Uint8Array>>({
async write(chunk) {
chunk.error(new Error("consumed error"));
await new Promise<void>((resolve) => setImmediate(resolve));
writeFinished = true;
throw new Error("writer error");
},
});
const writer = stream.getWriter();

await assert.rejects(
Consumable.WritableStream.write(writer, new Uint8Array([1, 2, 3])),
(error: unknown) => {
assert.strictEqual(writeFinished, true);
assert.ok(error instanceof Error);
assert.strictEqual(error.message, "writer error");
return true;
},
);
// ...
});

Two assertions do the work here. error.message === "writer error" checks
that the write error wins over the earlier consumed error. And
writeFinished === true checks the ordering — the helper must not settle
before the underlying write has actually completed, which is what a
concurrent Promise.all would allow. assert.strictEqual(writeFinished, true) inside the rejection predicate is a slightly unusual place for a
state assertion, but it is the natural one: the predicate runs at the moment
the rejection is observed, so it can assert on the world at that instant.

The suite uses node:test and node:assert directly rather than a
framework, and the process.on("unhandledRejection", ...) /
process.off(...) pair is wrapped in try / finally so a failed assertion
does not leak a listener into the next test — which matters, because a
leaked listener on unhandledRejection would make every later test in the
file observe rejections it did not cause.

A note on MaybeConsumable

The same ConsumableWritableStream implementation backs
MaybeConsumable.WritableStream (via
libraries/stream-extra/src/maybe-consumable/writable.ts), so the fix
covers both variants — the CodeRabbit “Linked Issues check” spelled this
out:

This shared ConsumableWritableStream implementation covers the linked
Consumable.WritableStream and MaybeConsumable.WritableStream scope.

MaybeConsumable is the type used by the ADB sync service
(ReadableStream<MaybeConsumable<Uint8Array>>), which is why the fix
matters for file transfer, not just command dispatch.

Where this stands

As of this writing:

  • Issue #876 — open.
  • PR #877 — closed,
    superseded.
  • PR #878 — open, one
    commit, awaiting review from yume-chan.
  • No release. The latest published @yume-chan/stream-extra is still
    3.0.0-beta.4 (published 2026-10-09), and 3.0.0-beta.4 predates this
    fix entirely. npm view @yume-chan/stream-extra dist-tags still reports
    latest: 2.6.1, beta: 3.0.0-beta.4.

The last stream-extra commit on main remains 59e95a0e — “fix(pcm-player):
fix lint error”
, 2026-10-30 — so this change has not landed on main in
any form yet.

A taxonomy of unhandled rejections

This is the fourth unhandled-rejection bug I have tracked in ya-webadb’s
history, and it is worth naming the pattern because they are all the same
shape wearing different clothes:

Issue What leaks Where
#862 AdbServerClient.createTransport unhandled rejection on disconnect adb-server
#859 subprocess wait does not close the process when stdin piping fails adb subprocess
#863 prevent unhandled disconnect rejection adb (fixed)
#876 Consumable.WritableStream.write orphans consumed stream-extra

The common structure: a value is produced by one promise and consumed by
another, and the code that owns both can exit early
. Whenever you have
promiseA and promiseB where failing A implies failing B, attaching
the handler to B after awaiting A is a latent bug — the rejection of
B will outlive the code path that was supposed to observe it.

The void promise.catch(() => {}) idiom is the cheapest fix for that class:
mark it handled eagerly, keep the awaits sequential, and preserve whichever
error-precedence rule you had. #878 is the textbook application.

The other lesson is about the review itself. The first fix was not wrong in
the “it doesn’t compile” sense, or the “it doesn’t fix the bug” sense. It
fixed the reported problem completely and introduced a different one — one
that only shows up when the consumer rejects first and the writer second,
which is a rare interleaving that no ordinary test would produce. It took an
AI reviewer reading the diff specifically for “preserve error precedence” to
find it, and it took a second implementation to get right.

That is a good argument for writing the precedence assertion as an
explicit test rather than trusting the code to look obviously correct. In
the end, #878’s second test does exactly that: it constructs the rare
interleaving on purpose and asserts on which error the caller sees.

What this means for webadb.online

Directly relevant, and the fix is on our critical path.

webadb’s TCP transport in lib/adb-daemon-tcp.ts builds its writable side
from ya-webadb’s AdbPacketSerializeStream, which hands back a
WritableStream<Consumable<AdbPacketInit>> — the exact type whose
Consumable.WritableStream.write helper is being fixed:

1
2
3
4
const serialize = new AdbPacketSerializeStream();
const packetWritable = serialize.writable as unknown as WritableStream<
Consumable<AdbPacketInit>
>;

So when a phone drops off the wireless-debugging socket, our packet writes
go through the buggy path. In the browser the symptom is a console
UnhandledPromiseRejection warning and a rejected promise the UI never
expected — our panels already treat write failures as normal (the device
went away), so the visible effect today is noise in the console rather than a
crash. In Node.js, where the same @yume-chan/stream-extra code runs inside
the ya-webadb CLI tooling, it is fatal.

There is nothing to patch in webadb itself: the fix belongs upstream, in
ConsumableWritableStream.write, and it is one line. What we can do is
track PR #878 until it merges and ships in a stream-extra release, then
bump the dependency — which for us means moving off
@yume-chan/[email protected] to whatever beta lands next.

Until then, the practical mitigation on our side is the one that costs
nothing: our write paths already catch and log socket failures, and adding
a process.on("unhandledRejection") filter in any Node-side tooling we ship
would keep a peer disconnect from taking the process down. In the browser
there is nothing to do — the warning is harmless and the upstream one-line
fix removes it entirely.

One last note on process: the Chromium Direct Sockets check that runs on
Mondays is skipped this week — today is Tuesday, and that source is
weekly by design.

ya-webadb learns to close its sockets properly, and adopts Array.fromAsync

ya-webadb merged a 585-line refactor yesterday that does something it should
have done a long time ago: when you cancel the readable side of an ADB daemon
socket, it now actually tells adbd to close the connection, by sending an
explicit CLOSE packet. The commit is 0c68ad2f, PR
#879, merged at
2026-11-04T17:25:18Z. In the same PR the library picks up
Array.fromAsync (with a hand-written fallback for engines that lack it),
rewrites the Node.js TCP transport to use node:stream‘s own web-stream
adapters instead of hand-rolling them, and starts validating its logcat
arguments instead of silently passing garbage to the device.

Three separate npm packages moved on the same day. @yume-chan/async went
4.1.3 → 4.2.0 → 4.2.1 → 4.2.2, the last three published inside four
hours of each other on November 4, and the final one is the version that
introduces noop and suppressUnhandledRejection — the two helpers the
refactor leans on most.

The socket close bug: cancelling a stream now closes the ADB connection

This is the substantive change, and it is in
libraries/adb/src/daemon/socket.ts.

The ADB daemon protocol multiplexes several logical connections (“sockets”
in adb’s vocabulary — shell:, sync:, tcp:, and friends) over one USB
or TCP transport. Each one is a separate stream pair with its own local and
remote id. The wire protocol has a CLOSE command, and it has to be sent
explicitly; the remote side is not going to infer the close from the USB
transport dropping.

The old AdbDaemonSocketController had no mechanism for this. If a consumer
called readable.cancel() — which is exactly what happens when a shell
session’s PTY is torn down, or a sync: pull is abandoned mid-file — the
library simply stopped enqueuing data. The CLOSE packet was never sent.
adbd on the device kept the socket open, waiting for more data on a
connection nobody would ever read from.

The fix hooks into the abort signal that PushReadableStream already
exposes:

1
2
3
4
5
6
7
8
9
this.#readable = new PushReadableStream((controller) => {
this.#readableController = controller;

controller.abortSignal.addEventListener(
"abort",
() => void this.close().catch(noop),
{ once: true },
);
});

PushReadableStreamController.abortSignal is now documented in the
stream-extra type declarations as “an AbortSignal that aborts when the
stream is cancelled by the consumer”
— before this PR it existed but was
undocumented, and almost nobody knew it was the hook you were supposed to
use.

The PR adds a regression test that pins the behaviour precisely
(libraries/adb/src/daemon/socket.spec.ts):

1
2
3
4
5
6
7
8
9
10
11
12
controller.enqueue(new Uint8Array([1, 2, 3]));

assert.strictEqual(sendPacket.mock.calls.length, 0);

await controller.readable.cancel();

assert.strictEqual(sendPacket.mock.calls.length, 1);
assert.deepStrictEqual(sendPacket.mock.calls[0]?.arguments, [
AdbCommand.Close,
1, 2,
EmptyUint8Array,
]);

Read that carefully: the close packet carries the local and remote ids and
an empty payload, and it is sent exactly once. close() opens with an
if (this.#closed) return; guard, so a socket closed twice sends one
CLOSE packet, not two — a protocol violation the ADB daemon would
interpret as a mismatched close.

Two smaller fixes in the same file matter for the same reason. enqueue()
now early-returns if the socket is already closed, so a late-arriving chunk
cannot be pushed into a stream that has already errored:

1
2
3
4
5
6
async enqueue(data: Uint8Array) {
if (this.#closed) {
return;
}
await this.#readableController.enqueue(data);
}

And the write-back-pressure promise is now nulled out when it is rejected,
rather than being rejected-and-retained. Previously the rejected promise
stayed reachable on the instance, which meant a second close() would try
to reject it again — a no-op at best, and a double-settle hazard at worst:

1
2
3
4
if (this.#availableWriteBytesChanged) {
this.#availableWriteBytesChanged.reject(new Error("Socket closed"));
this.#availableWriteBytesChanged = undefined;
}

The dispatcher dependency was also narrowed. AdbDaemonSocketInit used to
take a full AdbPacketDispatcher; it now takes an
AdbDaemonSocketDispatcher, a three-member interface
(options.maxPayloadSize, sendPacket). That is a genuine API change for
anyone constructing a socket directly, but it makes the socket’s actual
dependencies visible in the type instead of hiding them behind the full
dispatcher surface.

Array.fromAsync, and a polyfill that does something slightly clever

The PR deletes ToArrayStream — an AccumulateStream subclass that
collected a stream into an array — and replaces every call site with
Array.fromAsync. Service.readdir() in
libraries/adb/src/service/sync/service.ts
loses its explicit TODO: Convert to Array.fromAsync comment along with
six lines of code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// before
async readdir(path: string) {
// TODO: Convert to `Array.fromAsync`
const results: OpenDir.Entry[] = [];
for await (const entry of this.opendir(path)) {
results.push(entry);
}
return results;
}

// after
readdir(path: string) {
return arrayFromAsync(this.opendir(path));
}

The same substitution happens in the two subprocess spawners, where
process.stdout.pipeThrough(new ToArrayStream()) becomes
arrayFromAsync(process.stdout). This is the path that every adb shell
command in webadb goes through, so it is worth noting what changed
structurally: the old code pushed chunks into a preallocated array through a
TransformStream; the new code drains the async iterable directly. One
fewer stream in the chain.

Because Array.fromAsync is not available everywhere, the library ships a
fallback in a new file,
libraries/adb/src/utils/array-from-async.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export async function arrayFromAsyncPolyfill<T>(
items: AsyncIterable<T> | Iterable<MaybePromiseLike<T>>,
) {
const result: T[] = [];
let index = 0;
for await (const item of items) {
Object.defineProperty(result, index, {
value: item,
writable: true,
enumerable: true,
configurable: true,
});
index += 1;
}
result.length = index;
return result;
}

export const arrayFromAsync = Array.fromAsync ?? arrayFromAsyncPolyfill;

The Object.defineProperty dance is worth pausing on. A plain
result[index] = item would work identically here, because for await
already unwraps promises. The defineProperty form is there so that the
property is explicitly marked enumerable: true — which is the default for
array index properties anyway — but also writable and configurable, which
matches array index semantics exactly. The author calls it “a simplified
version that only works with sync/async iterables that fits our use case”
,
and the type signature reflects the narrowing: no array-like objects, no
mapFn, no thisArg.

I extracted the implementation and ran it against the three fixtures from the
PR’s own spec file:

1
2
3
4
async iterable: [ 1, 2, 3 ] isArray: true len: 3
sync iterable: [ 4, 5, 6 ] isArray: true
sync iterable w/ promises: [ 7, 8, 9 ]
native Array.fromAsync: function

The third case is the one the type signature is really about: a synchronous
iterable that yields promises. for await unwraps each one, so the resulting
array holds resolved values, and the polyfill’s return type
Iterable<MaybePromiseLike<T>> matches that behaviour.

Array.fromAsync has been Baseline widely available since January 2024,
so the polyfill is belt-and-braces in any browser we care about. But
ya-webadb ships to Node.js, Deno, Bun, and workers too, and the
?? arrayFromAsyncPolyfill fallback costs nothing at runtime.

What is more interesting is what the polyfill’s existence signals: the
stream-extra library has been maintaining its own ReadableStream.from
shim for a while, and this PR changes its behaviour.

ReadableStream.from now has a genuinely separate sync path

In
libraries/stream-extra/src/global/streams.ts,
the polyfill for the (now standard) ReadableStream.from() static method
used to handle sync and async iterables with one code path — it checked
Symbol.asyncIterator in iterable, picked an iterator, and then used an
async pull() regardless.

The new version splits them:

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
if (Symbol.asyncIterator in iterable) {
const iterator = iterable[Symbol.asyncIterator]();
return new ReadableStream({
async pull(controller) {
const result = await iterator.next();
if (result.done) {
controller.close();
return;
}
controller.enqueue(result.value);
},
async cancel(reason) {
await iterator.return?.(reason);
},
});
} else {
const iterator = iterable[Symbol.iterator]();
return new ReadableStream({
pull(controller) { // ← no longer async
const result = iterator.next();
if (result.done) {
controller.close();
return;
}
controller.enqueue(result.value);
},
cancel(reason) {
iterator.return?.(reason);
},
});
}

The sync branch’s pull is now a plain synchronous function, and its
cancel is synchronous too. That is not cosmetic: a synchronous pull
means the stream can produce its first chunk without ever yielding to the
microtask queue, which removes a tick of latency from the start of every
sync-iterable-backed stream.

ReadableStream.from is used in exactly the place you would expect from
the scrcpy client, in
libraries/adb-scrcpy/src/client.ts,
which previously had two hand-rolled helpers for exactly this:

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
// before
function arrayToStream<T>(array: T[]): ReadableStream<T> {
return new PushReadableStream(async (controller) => {
for (const item of array) {
await controller.enqueue(item);
}
});
}

function concatStreams<T>(...streams: ReadableStream<T>[]): ReadableStream<T> {
return new PushReadableStream(async (controller) => { /* ... */ });
}

// after
/**
* Polyfill for `Iterator.concat()`
*
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/concat
*/
async function* concatIterables<T>(
...iterables: (Iterable<T> | AsyncIterable<T>)[]
): AsyncIterable<T> {
for (const iterable of iterables) {
yield* iterable;
}
}

// ... and at the call site
output: ReadableStream.from(concatIterables(lines, output)),

The comment is explicit that concatIterables is a polyfill for
Iterator.concat() — a Stage 3 proposal that does not exist in any shipping
browser yet, which is why the generator function stays. The important part is
that the scrcpy client’s output stream is now built from an async generator
rather than a PushReadableStream with a manual await controller.enqueue()
loop. The backpressure semantics of PushReadableStream and a generator-backed
ReadableStream differ: the latter lets the stream implementation decide how
much to pull ahead, and ReadableStream.from handles that correctly by
pulling one item at a time.

The Node.js TCP transport stops hand-rolling streams

@yume-chan/adb-server-node-tcp exists to let Node.js code talk to a local
adb server over TCP — useful for the ya-webadb CLI and for any tool that
wants the daemon protocol without WebUSB. Its connector used to bridge
node:net‘s Socket to web streams by hand, with a PushReadableStream,
a pause()/resume() dance around each enqueue(), and a
MaybeConsumable.WritableStream wrapping socket.write().

The new version replaces all of that with node:stream‘s official web-stream
adapters:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Readable, Writable } from "node:stream";

return {
readable: Readable.toWeb(socket) as never,
writable: new MaybeConsumable.WrapWritableStream<Uint8Array>(
Writable.toWeb(socket) as never,
),
get closed() {
return closedResolver.promise;
},
close() {
socket.end();
},
};

The as never casts are ugly but honest: Readable.toWeb() returns a
ReadableStream<any> from the global scope, and the library’s
ReadableStream type comes from its own stream-extra compatibility layer,
so the two are nominally different types even though they are the same
runtime object.

The listener handling was also cleaned up. The old code attached a close
listener that resolved a promise and left it attached forever; the new code
uses a PromiseResolver plus an explicit cleanup() that removes the
listeners:

1
2
3
4
5
6
7
8
9
10
11
12
13
const handleClose = () => {
closedResolver.resolve(undefined);
cleanup();
};
const handleError = (e: Error) => {
closedResolver.reject(e);
cleanup();
};

const cleanup = () => {
socket.removeListener("close", handleClose);
socket.removeListener("error", handleError);
};

Note that handleError now rejects the closed promise where the old
code’s closed promise only ever resolved. A rejected closed promise that
nobody awaits is exactly the kind of thing that produces an
UnhandledPromiseRejection crash in Node — which is why the very next line
is:

1
suppressUnhandledRejection(closedResolver.promise);

That helper and its companion noop are the subject of the next section,
because they are what the rest of this refactor is built on.

noop and suppressUnhandledRejection: two helpers, six call sites

The new helpers come from @yume-chan/async 4.2.2, and their implementations
are almost comically small:

1
2
3
4
5
6
7
// no-op.js
export function noop() {}

// suppress-unhandled-rejection.js
export function suppressUnhandledRejection(promise) {
promise.catch(noop);
}

suppressUnhandledRejection is promise.catch(noop) with a doc comment and
an ESLint-disabling intent. The value is not the code — it is that the
intent becomes greppable and that the ESLint no-floating-promises rule
is satisfied without a // eslint-disable-next-line on every line.

The library previously had its own NOOP constant in
libraries/adb/src/utils/no-op.ts, marked /* #__NO_SIDE_EFFECTS__ */ for a
side-effect-detection toolchain that has since been removed. This PR deletes
that file and switches every call site to the @yume-chan/async versions.

The call sites that changed:

  • libraries/adb/src/service/sync/compression/native.ts —
    stream.writable.abort().catch(NOOP) → .catch(noop)
  • libraries/adb/src/service/sync/request/push.ts — this.#resolver.promise.catch(NOOP)
    → suppressUnhandledRejection(this.#resolver.promise)
  • libraries/stream-extra/src/consumable/writable.ts —
    void consumable.consumed.catch(() => {}) → suppressUnhandledRejection(consumable.consumed)
  • libraries/stream-extra/src/delayed-close-writable.ts — same substitution
  • libraries/stream-extra/src/task-queue.ts — same substitution
  • libraries/scrcpy/src/control/writer.ts — suppressUnhandledRejection(result[1])
  • libraries/adb-server-node-tcp/src/connector.ts — as above

One of these is worth singling out, because it is a direct follow-on from
yesterday’s post. ConsumableWritableStream.write is exactly the method that
PR
#878
(ac8447b3, “fix(stream-extra): prevent unhandled rejection in
Consumable.WritableStream.write”
) fixed. This PR does not change the
behaviour — it changes the spelling of the fix, moving the
catch(() => {}) into the shared helper. That is the natural follow-through
after a fix like that lands: the first commit patches one call site, the
second commit notices the same pattern in five other places and gives it a
name.

logcat arguments are validated before they reach the device

The last substantive change is in
libraries/android-bin/src/logcat.ts,
and it is the kind of fix that only shows up in a code review.

The Logcat.binary() method builds a logcat command line. Previously it
used truthiness checks:

1
2
3
if (options?.pid) {
args.push("--pid", options.pid.toString());
}

options.pid = 0 is falsy, so it was silently dropped — a request to filter
by PID 0 (which on Linux means “the kernel’s scheduler thread group”, a real
thing on Android) would produce a logcat invocation with no --pid filter
at all, and you would get every log line from every process with no
indication that anything went wrong. The new code is explicit:

1
2
3
4
5
6
if (options.pid !== undefined) {
if (!Number.isSafeInteger(options.pid) || options.pid <= 0) {
throw new Error("PID must be a positive integer");
}
args.push("--pid", options.pid.toString());
}

The same treatment for tail:

1
2
3
4
5
6
7
8
if (typeof options.tail === "number") {
if (!Number.isSafeInteger(options.tail) || options.tail <= 0) {
throw new Error("Tail must be a positive integer");
}
args.push("-t", options.tail.toString());
} else if (options.tail) {
args.push("-t", formatTailTime(options.tail));
}

Note the restructuring: the tail branch moved out of the if (options)
block, because options.tail is meaningful even when options is otherwise
falsy-ish. And the typeof === "number" check now runs before the truthiness
check, so tail: 0 throws instead of falling through to the “no tail
argument” case.

Logcat.logIdToName() had a related problem — LogIdName[id]! used a
non-null assertion to silence TypeScript about the record lookup, which would
return undefined at runtime for an unknown id:

1
2
3
4
5
6
7
static logIdToName(id: LogId): string {
const value = LogIdName[id];
if (!value) {
throw new Error(`Unknown LogId: ${id}`);
}
return value;
}

And in the scrcpy clipboard code,
libraries/scrcpy/src/1_21/impl/set-clipboard.ts,
a resolver.reject() with no argument became a real error:

1
2
3
4
5
6
7
8
9
close(): void {
for (const resolver of this.#resolvers.values()) {
resolver.reject(
new Error("Control socket closed before acknowledgment"),
);
}
this.#resolvers.clear();
this.#closed = true;
}

Rejecting with undefined produces a rejection whose reason is undefined,
which is much harder to debug than a message — you get Unhandled rejection (undefined) in the console and nothing else.

Two smaller fixes worth noting

IPv6 bracket parsing. In
libraries/adb-server-node-tcp/src/socket-spec.ts, the bracketed-IPv6 branch
shadowed the outer port variable with a const:

1
2
3
4
// before
const port = decimalToNumber(spec, end + 2);
// after
port = decimalToNumber(spec, end + 2);

The outer port was later unconditionally reset with port = undefined; —
which was fine in the old code because the bracket branch returned early, but
would have been a bug if the flow ever changed. The change removes the
shadowing and the reset. The parser is a port of AOSP’s
socket_spec.cpp / parsenetaddress.cpp, and the file carries the
cs.android.com permalinks so the two can be diffed.

WrapWritableStream.bePipedThroughFrom is gone. This method was removed
from libraries/stream-extra/src/wrap-writable.ts without a replacement. If
you were using it, you now write the equivalent by hand:

1
2
3
4
5
6
7
8
9
10
11
12
13
// the removed method
bePipedThroughFrom<U>(transformer: TransformStream<U, T>) {
let promise: Promise<void>;
return new WrapWritableStream<U>({
start: () => {
promise = transformer.readable.pipeTo(this);
return transformer.writable;
},
async close() {
await promise;
},
});
}

There is one more change in the diff that is easy to miss and worth
understanding, because it is not a code change at all — it is a metadata
change in pnpm-workspace.yaml:

1
2
minimumReleaseAgeExclude:
- "@yume-chan/[email protected]"

pnpm’s minimumReleaseAge setting refuses to install packages published
more recently than some threshold, which is a supply-chain hardening measure
against compromised fresh releases. The exception list carves out
@yume-chan/[email protected] specifically — the author needed the brand-new
version and did not want to wait out the delay. It is a small, honest piece
of metadata: it says “this one release was published hours ago and we took it
anyway”.

What this means for webadb.online

Mostly this is upstream work with no direct impact on webadb this week, with
one exception worth flagging.

No impact on webadb this week. webadb.online is still on
@yume-chan/[email protected] and the whole 3.0.0-beta.1 line, and this
PR is unreleased — it landed on main after the v3.0.0-beta.4 tag
(7bcec613, October 9), so there is no npm artifact to consume yet. The
changes that would reach us via a future beta are the socket CLOSE packet
and the ReadableStream.from sync-iterator split.

The CLOSE packet is the one with a plausible user-visible effect. Our
Terminal panel creates a PTY per window; when a window is closed, the PTY is
torn down and the underlying ADB socket is cancelled. On the current
3.0.0-beta.1, that cancellation does not tell adbd anything, so the device
keeps the shell: socket alive until the whole USB transport goes away.
On a phone where the user opens and closes several Terminal windows in a
row, that is a slow accumulation of orphaned sockets on the device side.
Upgrading to a release containing 0c68ad2f would make our teardown
explicit and bounded.

The logcat argument validation is not directly useful to us either — our
Logcat panel builds its own logcat command line rather than going through
@yume-chan/android-bin‘s Logcat class — but the options.pid !== undefined
fix is a good reminder for our own shell-out code, where we do pass --pid
values that come from a UI filter and could legitimately be 0.

The ReadableStream.from sync-path split is the one change that might make
itself felt without a version bump, because it lands in stream-extra, which
we do depend on at ^3.0.0-beta.1 and which is a separate npm package with
its own version line. But we do not currently call ReadableStream.from with
a synchronous iterable anywhere in our own code — I checked — so the practical
benefit for us is zero today. It would matter if we adopted it.

One process note: the Chromium Direct Sockets check that runs on Mondays was
skipped this week. Today is Thursday, and that source is weekly by design.

Chrome 157 goes stable, and WebUSB's disconnect semantics finally changed

Chrome 157 reached the stable channel on Tuesday, November 3, 2026 — version
157.0.8103.38 on Linux and 157.0.8103.38/.39 on Windows and macOS, with
213 security fixes (7 rated critical, 6 of them memory-safety bugs in
graphics and rendering code). The same build shipped to Chrome for Android
(157.0.8103.38) and Chrome for iOS (157.0.8103.37) the same day. Microsoft
Edge 157 follows on November 5 with the same platform features.

That release is the headline, but the part that actually touches webadb is
smaller and quieter: Chromium’s WebUSB implementation now rejects a pending
transferIn() with NotFoundError when the device is unplugged, instead of
the generic NetworkError the WebUSB specification still describes. Two
upstream projects noticed — the WebUSB spec’s own issue tracker and
ya-webadb — and ya-webadb’s fix is on npm right now, in
@yume-chan/[email protected]. webadb.online is still running
3.0.0-beta.1. That gap is three weeks wide and it covers a rewrite of the
exact WebUSB writable path our device transport uses.

Chrome 157: the platform webadb actually runs on

The stable channel notes for 157 are modest by recent standards, and most of
it is unrelated to what we do:

  • text-overflow accepts a custom <string> value, so you can render
    " (more)" instead of the ellipsis. Firefox has shipped this since 2011;
    Chrome is catching up roughly fifteen years later. The corner shorthand
    lands too, folding border-radius and corner-shape into one declaration
    (corner: 20px squircle), with per-corner and logical variants.
  • A family of HTML insertion and streaming methods —
    beforeHTML(), afterHTML(), appendHTML(), prependHTML(),
    replaceWithHTML(), plus streamHTML() / streamAppendHTML() returning
    WritableStreams — aimed at replacing insertAdjacentHTML() with one
    coherent, Trusted-Types-aware story. The ChromeStatus entry is still
    marked “pending review”, so we would not ship against it yet.
  • The Sanitizer API grows an explicit javascriptURLs option instead of
    the behaviour being a side effect of which method you called.
  • The Background Fetch API now enforces CORS, closing a bypass where a site
    could skip CORS and Local Network Access checks by using Background Fetch
    instead of fetch().
  • Long Animation Frames gains styleDuration and forcedStyleDuration,
    so you can finally tell whether a janky frame came from style recalc or
    from a script forcing layout.
  • The Language Detector API ships on Android (Clank), and
    BluetoothRemoteGATTServer becomes an EventTarget.

None of that is what we build on. What we build on is navigator.usb, and
the one item in the 157 cycle that touches USB is a WebUSB hardening change
in services/device/usb/mojo/device_impl.cc — commit 89b42d2d, “WebUSB:
Strengthen control transfer protection for protected classes”
, bug
489711638:

1
2
3
4
5
6
Strengthens control transfer permissions to block requests targeting
protected interface classes like HID or Mass Storage. Requests are now
scrutinized for protected interfaces via the wIndex field regardless of
recipient. Standard requests for device-level management remain allowed.
This behavior is gated by the kWebUsbProtectedClassControlTransferBlock
feature flag.

The protected-class list is HID (0x03), mass storage (0x08), and the
other class-code owners the OS already has kernel drivers for. ADB’s interface
is vendor-specific (0xFF), so this does not touch our traffic — but the
mechanism is worth understanding, because it is the same wIndex-based
scrutiny that made the disconnect behaviour change described below legible in
the first place. (Electron cherry-picked this same Chromium commit into
39-x-y on April 1, 2026, so it predates the 157 cycle — it is in the stable
channel already, and is not what I am writing about today. The disconnect
change is.)

The disconnect semantics change, and the spec that did not follow

The interesting change is not in the release notes at all. It is in
whatwg/usb issue #219,
“Can’t detect device disconnection when transferIn is waiting incoming
data”
, filed back in 2022 and still open.

The problem, as originally reported: in a polling design, transferIn() is
called continuously to receive incoming data. If the device disconnects,
transferIn step 14 throws a generic NetworkError. The disconnect event
is fired after the error was thrown (at least on Windows and Chrome), which
makes it impossible to distinguish “the cable fell out” from “the device
misbehaved”. The issue proposes two things: throw NotFoundError from
transferIn when the failure reason is device disconnection, and precisely
define the ordering of the disconnect event, the device.open property
change, and the transferIn rejection.

On 2026-10-01, kvnloo posted the answer as a comment:

Current Chromium looks like it may have moved since this was filed.
USBDevice::OnConnectionError() now rejects every pending
device_requests_ resolver with NotFoundError and "The device was disconnected.", including an outstanding transferIn().

That seems to implement the first suggestion here already. A focused WPT
that starts a pending transferIn(), disconnects the fake device, and
asserts NotFoundError would tell us whether this can now be closed or
whether there is still a platform-specific ordering problem.

Note what this means in practice. The old code path had to guess. The
original Chromium behaviour — and the behaviour the spec still describes —
is that a disconnect surfaces as NetworkError, which is indistinguishable
from a transfer-level failure. The ya-webadb fix for that, in
libraries/adb-daemon-webusb/src/device.ts, is a heuristic: on NetworkError,
sleep 100 ms, then check whether the connection was closed in the meantime,
and only then treat it as a clean disconnect.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
} catch (e) {
// On Windows, disconnecting the device will cause `NetworkError` to be thrown,
// even before the `disconnect` event is fired.
// Wait a little while and check if the device is still connected.
// https://github.com/WICG/webusb/issues/219
if (isErrorName(e, "NetworkError")) {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 100);
});

if (closed) {
return undefined;
}
}

throw e;
}

That is a timer, a race, and a 100 ms window during which a real error is
indistinguishable from a cable pull. With the new Chromium behaviour there
is no ambiguity at all: NotFoundError means “the device was
disconnected”. The fix that landed on top of it is three lines:

1
2
3
4
5
6
7
8
9
     }

+ // https://github.com/whatwg/usb/issues/219#issuecomment-5937803105
+ if (isErrorName(e, "NotFoundError")) {
+ return undefined;
+ }
+
throw e;
}

return undefined is the sentinel this function already uses for “the
connection ended cleanly, stop the read loop”. So the new path is exact: no
timer, no race, no 100 ms of latency before the UI learns the phone is gone.

The WebUSB specification has not caught up. The draft community group report
is dated 5 June 2026 and still describes the old generic NetworkError
behaviour, and issue #219 remains open pending a focused WPT. That gap is
worth naming, because code written today against the spec rather than
against Chromium will still have the heuristic in it — and the heuristic is
still correct as a fallback for browsers that have not shipped the change.
The two paths coexist in the same catch block, which is the right shape:
NetworkError is ambiguous and needs disambiguation, NotFoundError is not.

What else changed in ya-webadb since beta.1

3.0.0-beta.4 was published to npm on 2026-10-09, the same day as the
release commit 7bcec613. Between v3.0.0-beta.1 and v3.0.0-beta.4 there
are fifteen commits, and two of them touch code that webadb.online runs
directly.

The WebUSB writable path was rewritten

PR #869, “fix(adb): wait
for sync send v1 completion”
, merged 2026-09-17 as 69fffaf0 (co-authored
by Qingyu Wang), rewrote the writable half of
AdbDaemonWebUsbConnection. In beta.1 it was:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
this.#writable = pipeFrom(
duplex.createWritable(
new MaybeConsumable.WritableStream({
write: async (chunk) => {
await device.raw.transferOut(
outEndpoint.endpointNumber,
// WebUSB doesn't support SharedArrayBuffer
// https://github.com/WICG/webusb/issues/243
toLocalUint8Array(chunk),
);
// ... zero-length packet handling ...
},
}),
),
new AdbPacketSerializeStream(),
);

In beta.4 it is:

1
2
3
4
5
6
7
8
9
10
11
const serializeStream = new AdbPacketSerializeStream();
this.#writable = new DelayedCloseWritableStream(
serializeStream.writable,
serializeStream.readable.pipeTo(
duplex.createWritable(
new MaybeConsumable.WritableStream({
write: async (chunk) => { /* same transferOut body */ },
}),
),
),
);

The pipeFrom helper was deleted outright (libraries/stream-extra/src/pipe-from.ts,
28 lines removed) and replaced by DelayedCloseWritableStream, a new
78-line class in libraries/stream-extra/src/delayed-close-writable.ts.

The name tells you the bug. pipeFrom starts the source pump eagerly and
returns a writable that writes into it. Nothing in that shape guarantees the
pump has drained before the writable is closed. The ADB sync service’s
sendV1 — the v1 push protocol that adb push uses — writes a DONE
request and then closes the stream, and the server’s OKAY response arrives
after that. With pipeFrom, writer.close() could resolve before the
final OKAY was ever read, so push would report success for a transfer
that the device had not actually acknowledged.

DelayedCloseWritableStream closes that hole by taking the pump promise
explicitly and making close() wait for both:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async close() {
const [closeResult, promiseResult] = await Promise.allSettled([
writer.close(),
promise,
]);
if (closeResult.status === "rejected") {
if (
promiseResult.status === "rejected" &&
// Ignore promise rejection if the reason is known
promiseResult.reason !== closeResult.reason &&
typeof AggregateError !== "undefined"
) {
throw new AggregateError([
closeResult.reason,
promiseResult.reason,
]);
}
throw closeResult.reason;
}
if (promiseResult.status === "rejected") {
throw promiseResult.reason;
}
}

The reason !== closeResult.reason check is the subtle part: when the pump
fails for the same reason the close failed, that is one error, not two, and
throwing an AggregateError over it would obscure the actual cause. When
they differ, you genuinely have two failures and AggregateError is right.
The same discrimination appears in abort().

There is also a second, quieter fix in the same class — the same
void promise.catch(() => {}) idiom that
yesterday’s post
covered for Consumable.WritableStream.write:

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

It is not a coincidence that the same pattern shows up in two places in the
same library within three weeks. DelayedCloseWritableStream was written in
September; Consumable.WritableStream.write got the same treatment in the
open PR #878 on
November 2. The library author is converging on “attach the handler eagerly”
as the house style for promise pairs where one failing implies the other.

The credential store was rewritten around real IndexedDB semantics

PR #871, “fix(credential-web):
fix IndexedDB usage”
, landed 2026-10-02 as 7eaade3c — the fix for
issue #870, where
TangoIndexedDbStorage.load() always threw “callback must not be an async
function”
.

The root cause is a genuinely nasty IndexedDB rule: an IDBTransaction
auto-commits the moment the microtask queue drains. If you await anything
non-IndexedDB inside a transaction callback — even a resolved promise — the
transaction is already inactive by the time your next store.get() runs, and
you get TransactionInactiveError. The fix is to do all the database work
synchronously inside the transaction callback and await the request, never
the transaction.

The new libraries/adb-credential-web/src/storage/indexed-db/shared.ts
is built around three helpers — openDatabase, createTransaction, and
waitRequest — and the diff is 181 lines. waitRequest explicitly refuses
to work inside a transaction:

1
2
3
4
5
6
7
8
9
10
11
12
13
it("should throw if used inside a transaction", async () => {
const db = await openDatabase(nextDatabaseName(), 1, (db) => {
db.createObjectStore("store", { autoIncrement: true });
});
const tx = db.transaction("store", "readwrite");
const store = tx.objectStore("store");
const request = store.put({ value: 1 });
await assert.rejects(
() => waitRequest(request),
/Cannot wait for a request inside a transaction/,
);
db.close();
});

and createTransaction refuses async generators outright:

1
2
3
4
5
6
7
8
9
it("should reject async generators", async () => {
await assert.rejects(
// @ts-expect-error unit test
createTransaction(db, "store", async function* () {
yield new Promise((resolve) => setTimeout(resolve, 0));
}),
/Async generators are not supported/,
);
});

Both rules are the same rule viewed from two directions: the transaction’s
lifetime is bounded by the synchronous execution of its callback, and any
await that yields to the event loop ends it.

Two supporting changes ship alongside it. First, AdbWebCryptoCredentialManager
gains a close() method that forwards to the storage’s optional close(),
so the underlying IndexedDB connection can be released — previously there was
no way to close it at all. Second, the fix comes with a real test suite:
libraries/adb-credential-web/src/storage/indexed-db/shared.spec.ts,
272 lines, running against fake-indexeddb. The fake-indexeddb package
plus node:test is how the library tests IndexedDB behaviour at all, and it
is why these bugs are now caught before shipping rather than in a user’s
browser.

Where webadb.online actually stands

Here is the part that matters for us.

1
2
3
4
5
$ grep -o '"version": *"[^"]*"' node_modules/@yume-chan/*/package.json
@yume-chan/adb installed: "3.0.0-beta.1"
@yume-chan/adb-credential-web installed: "3.0.0-beta.1"
@yume-chan/adb-daemon-webusb installed: "3.0.0-beta.1"
@yume-chan/stream-extra installed: "3.0.0-beta.1"

All four of our @yume-chan/* dependencies are pinned three beta releases
behind what is on npm. npm view @yume-chan/adb dist-tags reports
latest: 2.6.4, beta: 3.0.0-beta.4, with 3.0.0-beta.4 published
2026-10-09.

The gap is not cosmetic. Concretely:

  • Our device transport still uses the pipeFrom-based writable path, so
    an adb push through the File Manager panel can in principle resolve
    before the device acknowledges the transfer. The push.spec.ts tests
    added in beta.3 assert exactly this ordering:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const closedBeforeResponse = closed;

    response.enqueue(
    Socket.NumberRequest.serialize({
    id: ResponseId.Ok,
    arg: 0,
    }),
    );
    await closePromise;

    assert.strictEqual(closedBeforeResponse, false);
    That assertion — closedBeforeResponse === false — is the test that would
    fail on beta.1.
  • Our disconnect handling still pays the 100 ms heuristic penalty. Our
    lib/adb-client.ts watchDisconnect listens for the navigator.usb
    "disconnect" event and races it against transport.disconnected. The
    upstream read loop, on beta.1, sleeps 100 ms on every ambiguous
    NetworkError before it can tell you the phone is gone. On beta.4 with
    a current Chrome, it would know immediately.
  • We do not use TangoIndexedDbStorage, so the IndexedDB rewrite is not
    directly a problem for us. We use TangoLocalStorage — new AdbWebCryptoCredentialManager(new TangoLocalStorage(ADB_KEY_STORAGE_KEY))
    at lib/adb-client.ts:88 — which stores the RSA key as base64 PKCS#8 in
    localStorage and sidesteps IndexedDB’s transaction-lifetime rules
    entirely. That was a deliberate choice when we moved to the 3.0 beta line
    and it remains the right one for us. The close() addition to
    AdbWebCryptoCredentialManager is still worth having: it gives us a
    deterministic teardown path for the credential manager when a session
    ends, rather than leaving it to garbage collection.

There is one more consequence of the upgrade that is not a bug fix at all.
beta.4 includes d8d1ddc8 — “feat(scrcpy): add version 5.0 and 5.0.1 as
aliases”
— which adds scrcpy 5.0 and 5.0.1 to the server-version alias
table. scrcpy 5.0 shipped on 2026-10-05 and 5.0.1 on 2026-10-08. Our
Screencast panel does not use the scrcpy server at all — it uses the
file-based adb shell screenrecord polling pipeline — but the alias table
is the library’s canonical list of known scrcpy server versions, and it is
worth knowing the library now speaks for a scrcpy generation that postdates
our own implementation choices.

Why the Chrome 157 timing makes this worth doing now

Chrome 157 rolled out to stable on November 3 and will reach users “over the
coming days/weeks” — the same rollout pattern as every Chrome release, but
it means the NotFoundError disconnect behaviour is now in the hands of the
general public rather than sitting behind a flag on a developer build.

That matters for a specific reason. The NotFoundError path in
AdbDaemonWebUsbConnection is a fast path: it returns immediately. The
NetworkError path is a slow path: it waits 100 ms, and if the connection
was closed in the meantime, it reports a clean disconnect. On a browser that
has not shipped the change, our code takes the slow path and the extra 100 ms
shows up as a small but real lag between “I yanked the cable” and “the UI
says the device is gone”.

Once Chrome 157 is the dominant stable build, the fast path is the default
and the heuristic becomes a fallback for older browsers rather than the
primary mechanism. Upgrading to beta.4 is what lets us take that path.

There is a broader point about the shape of these dependencies. ya-webadb is
pre-1.0 and its betas move fast: three releases in ten weeks, each carrying
behavioural changes to the transport layer that we consume. Our package.json
says ^3.0.0-beta.1, and npm’s caret semantics on a prerelease tag do not
mean “upgrade to any later beta” — they resolve to exactly what is installed
here, 3.0.0-beta.1, and stay there. Upgrading is a deliberate act, not an
automatic one, and it should be.

What this means for webadb.online

Directly relevant, and there is a concrete action item.

We are running 3.0.0-beta.1 of the ADB library stack while 3.0.0-beta.4
has been on npm since October 9. The two changes in that window that affect
us are the DelayedCloseWritableStream rewrite of the WebUSB writable path
(the adb push acknowledgement ordering) and the NotFoundError handling in
the read loop (the disconnect fast path). Both are the kind of change that is
invisible when it works and produces a confusing bug report when it does
not — a file upload that silently reports success, or a device that lingers
in “connected” state for a beat after you unplug it.

Our plan is to bump all four @yume-chan/* dependencies to 3.0.0-beta.4,
verify the File Manager upload path and the connect/disconnect cycle in the
browser against a real device, and confirm the push.spec.ts ordering
assertion holds on our transport. The TangoLocalStorage credential path is
unaffected, since beta.4‘s IndexedDB rewrite only touches
TangoIndexedDbStorage, which we do not use.

Beyond that, Chrome 157 is a good excuse to re-check our platform
assumptions in one place. Our _headers file already sets COOP/COEP/CORP
because ya-webadb’s SharedArrayBuffer usage requires cross-origin
isolation, and the toLocalUint8Array copy in the transfer path exists
because “WebUSB doesn’t support SharedArrayBuffer”
(whatwg/usb#243). Chrome 157
did not change any of that. But the release is a reasonable checkpoint for
re-reading the WebUSB spec draft (5 June 2026) against what Chromium
actually does — because as of kvnloo‘s comment on
whatwg/usb#219,
the two have already diverged on disconnect semantics, and the spec is the
one that is behind.

One note on process: the Chromium Direct Sockets check that runs on Mondays
is skipped this week — today is Wednesday, and that source is weekly by
design.

Hello, webadb

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

What’s here

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

How it’s built

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

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

More soon.

Chrome 158 goes early stable, and the ADB-in-browser feature set finally lands on Android

Chrome 158 started its early-stable rollout on desktop on November 4, and on
November 5 Google shipped Chrome 158 for Android (158.0.8127.22) to “a small
percentage of users” alongside Chrome 157 for Android (157.0.8103.46). That
second release is the one that matters for this blog, because the Android
stable channel is where webadb actually lives — most people running an
ADB-in-the-browser tool are on a phone, and they get Chrome updates on a
completely different cadence than desktop.

Chrome 158’s feature list is the first release where three things that the
ADB-in-browser ecosystem has been waiting on all land in the same build:
Source-Specific Multicast for Direct Sockets, the WebHID API on Android, and
the first real wave of WebTransport ergonomics (reliability attributes,
congestion control, exportKeyingMaterial). None of them are ADB features.
All of them change what a browser can do to a device that webadb wants to
reach.

The other half of the story is quieter: ya-webadb did not merge anything in
the last 24 hours. The upstream repository’s origin/main head is still
0c68ad2f — PR #879, the socket-close refactor — which landed at
2026-11-04T17:25:18Z and which we covered yesterday.
The npm registry confirms it: the last @yume-chan/* publish is
@yume-chan/async 4.2.2 at 2026-11-04T07:35:16Z. So this post is a
Chrome-side post, and it is worth reading as one — the interesting movement
in ADB-in-browser right now is in the browser, not in the library.

Source-Specific Multicast for Direct Sockets, and why the IDL matters

The Direct Sockets entry in Chrome 158’s release notes is
Source Specific Multicast for Direct Sockets API,
tracking bug
#461262401, shipping on
milestone 152 and landing in 158.

We have covered the SSM story twice already — once when the
spec change landed in the WICG repo
in November 2025, and once when the
chromestatus merger entry gave it three LGTMs.
What is new today is not the feature but the shape of the API, which the
finalized spec now pins down. The Direct Sockets specification at
wicg.github.io/direct-sockets
defines two dictionaries:

1
2
3
4
5
6
7
8
dictionary MulticastGroupOptions {
DOMString sourceAddress;
};

dictionary MulticastMembership {
DOMString groupAddress;
DOMString sourceAddress;
};

and the spec text is explicit about the semantics:

The optional IP address of the source for Source-Specific Multicast (SSM).
When present, creates or references a source-specific membership that only
receives multicast packets from this specific source address to the group
address. When absent, creates or references an Any-Source Multicast (ASM)
membership that receives packets from any source. The source address must be
a unicast address and match the IP version of the group address.

So the API surface is a single optional string in a single options dictionary,
and the “IP version must match the group address” rule is a hard constraint —
an IPv6 group address cannot be joined with an IPv4 source and vice versa.
The usage example in the spec is correspondingly small:

1
2
3
// Join SSM group - receive only from 192.0.2.100
await multicastController.joinGroup("232.1.1.1", { sourceAddress: "192.0.2.100" });
console.log('Successfully joined SSM group!');

Two details are worth flagging for anyone planning to use this. First,
MulticastController is [Exposed=(Window, DedicatedWorker), SecureContext, IsolatedContext] — the IsolatedContext requirement means this is not
available to a normal web page, only to an Isolated Web App. That is the
same gating that applies to the whole of Direct Sockets, and it is why
Direct Sockets has never been a viable transport for a plain webadb-hosted
page. Second, the SSM story depends on the permission-policy merger that
shipped back in Chrome 151: direct-sockets-private was replaced with the
granular local-network and loopback-network policies, and the WICG
explainer added a follow-up rule that sending UDP to a multicast address
requires a permission policy
(WICG/direct-sockets#85,
July 2026). SSM without the policy is a no-op.

The practical upshot for webadb: Source-Specific Multicast makes Direct
Sockets useful for the one thing a debugging tool wants and a browser
historically forbade — reaching a device on a LAN without a cloud round-trip
and without accepting packets from every source on the segment.
Today,
adbd over TCP on a LAN is the fallback transport when WebUSB is
unavailable, and it is exposed through a plain fetch to 5555. That works,
but it is one-shot HTTP: no multiplexing, no push channel, and the browser’s
private-network access rules make it increasingly awkward to serve from a
public origin. A raw TCP socket to 192.168.x.x:5555 is a much better fit
for an ADB daemon, and Direct Sockets is the only browser API that can
provide it — but it is gated behind Isolated Web Apps, and SSM’s specific
value (receiving only from the one host you asked for) is a security property
that matters most in exactly the shared-LAN case where you’d use it.

We have been tracking the Direct Sockets chromestatus entries across several
Mondays and the merger entry has been frozen at updated: 2026-06-22 since
April. The feature entry finally moved to updated: 2026-10-27 when the
three LGTMs landed, and now it has a shipping milestone. The merger entry’s
Chrome status still reads "Proposed" with desktop_first: 151 even though
151 shipped months ago — that staleness is a known chromestatus data problem,
not a signal that the policy did not land.

WebHID comes to Android, and it is not about AOA

Chrome 158’s Device section
carries one entry: Support for WebHID,
tracking bug #40628009.

The substance is one sentence:

WebHID shipped on desktop platforms (Windows, macOS, Linux, and ChromeOS) in
Chrome 89. Chrome 158 brings the existing WebHID API to Chrome on Android
(relying on new OS support in Android 17 / API level 37 and later) with no
changes to the web-exposed API surface.

Two things follow. The API surface is unchanged, so nothing in the WebHID
spec changed — this is purely a platform availability change. And it depends
on Android 17 / API level 37, meaning that on Android 16 and below the API
simply will not exist, and on Android 17 it depends on the OS actually
shipping the HID host support the WebHID implementation sits on.

Now, the obvious reaction in an ADB-in-browser context is “does this affect
AOA?” It does not — at least not the way you might hope. ya-webadb’s
aoa
library implements Android Open Accessory protocol by issuing WebUSB
vendor control transfers directly to the phone:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export async function aoaHidRegister(
device: USBDevice,
accessoryId: number,
reportDescriptorSize: number,
) {
await device.controlTransferOut(
{
recipient: "device",
requestType: "vendor",
request: AoaRequestType.RegisterHid,
value: accessoryId,
index: reportDescriptorSize,
},
new ArrayBuffer(0),
);
}

That is navigator.usb, not navigator.hid. AOA’s HID mode is a USB
vendor-specific
protocol that happens to carry HID-shaped reports; it is
not the USB HID class, so the WebHID API has nothing to attach to. The
libraries/aoa/src/hid.ts module talks to the device through
device.controlTransferOut(), never through navigator.hid.requestDevice().

The WebHID-on-Android change matters for a different reason: it means the
browser on Android now exposes a second device-access API alongside
WebUSB, with a device chooser and a permission model that is separate from
the WebUSB one. Two implications for a tool like webadb. First, the
permission surfaces are separate, so a user could in principle grant HID
access while denying USB access — the “your device can’t be reached” error
message needs to be specific about which permission is missing. Second,
WebHID on Android is new enough that the browser-chooser UX is unproven;
the enterprise policy surface listed for the feature
(DefaultWebHidGuardSetting, WebHidAllowAllDevicesForUrls,
WebHidBlockedForUrls, and four others) is where a managed deployment
would lock it down, and on Android WebView is explicitly excluded — “WebView
is not supported, as it does not have a device chooser UI or permissions UX
controls.”

For our purposes the honest summary is: no code change, no new capability
for ADB, but a broader platform story that says Chrome on Android is
investing in hardware access rather than retreating from it. Chrome on
Android shipping WebUSB is what made webadb possible there in the first
place; WebHID landing in the same release suggests the trajectory continues.

The WebTransport wave, and the one thing worth stealing

Chrome 159’s release notes are already filling up with WebTransport work,
and while none of it is in 158 stable, the 158 entries are the leading edge
of a coherent push. In 158:

  • WebTransport reliability attributes
    (#5094058497277952) —
    a WebTransport.reliability instance attribute and a static
    WebTransport.supportsReliableOnly. In Chromium, reliability starts at
    "pending" and flips to "supports-unreliable" once an HTTP/3 session is
    established, because Chromium has no HTTP/2 fallback: every successful
    WebTransport session in Chromium is HTTP/3 with unreliable datagrams
    available.
  • WebTransport draining
    (#6427766428925952) —
    WebTransport.draining is a promise that resolves when the server
    indicates the session will be gracefully retired, so an app can stand up a
    replacement session while the old one is still usable. This is the
    server-initiated-handover primitive that a long-lived device session
    needs.
  • WebTransport anticipated concurrent incoming streams
    (#5089961022980096) —
    constructor hints for initial QUIC stream allowance.
  • WebTransport exportKeyingMaterial
    (#4860330806214656) —
    not 158 but already in the 159 notes, and it is the interesting one: the
    method derives cryptographic keying material bound to an established
    WebTransport session, and for HTTP/3 Chromium includes the WebTransport
    CONNECT stream ID in the TLS exporter context, so two sessions sharing one
    HTTP/3 connection derive different keying material.

That last property is the one worth stealing, and it has a direct analogue
in the ADB world. The ADB daemon protocol has no session identity of its own
beyond the socket’s local/remote id pair, and ya-webadb’s
AdbDaemonSocketController — which yesterday’s post covered in detail
— identifies a logical connection by exactly that pair. A transport-level
per-session secret, derivable by both ends and distinct per session, would
be a materially stronger way to bind a multiplexed ADB connection to the
thing that opened it. Chromium’s exportKeyingMaterial shows the shape of
that primitive exists in a shipping browser API; whether it can be applied
to the ADB protocol is a separate question.

Also worth noting for anyone tracking the WebTransport surface: 159 adds
WebTransport.getStats() (round-trip time, sending rate, expired/lost/dropped
datagram counts, readable after close), a congestionControl constructor
option accepting "default" | "throughput" | "low-latency", the
standardized WebTransportReceiveStream interface with per-stream
bytesReceived vs bytesRead counters, and send groups with sendOrder
prioritization.

What did not happen

Being explicit about the absences, because a daily post that only lists what
shipped reads as marketing.

ya-webadb merged nothing. origin/main is 0c68ad2f, and the commit
window 2026-11-04T01:00:00Z → 2026-11-06T01:00:00Z contains exactly two
commits: ac8447b3 (the Consumable.WritableStream.write unhandled-rejection
fix, PR #878) and 0c68ad2f (PR #879). Both were covered by the Nov 3 and
Nov 5 posts respectively. There are no open PRs newer than #879, no issues
filed since #876, and the npm registry’s most recent @yume-chan/* publish
is @yume-chan/async 4.2.2 from 2026-11-04T07:35:16Z.

The Direct Sockets and WebUSB specs did not move. The WICG
direct-sockets repository’s last commit is a75fe711 from July 10, 2026
(the multicast permission-policy rule). The whatwg/usb repository — which
the WebUSB spec now lives in — last saw a commit on June 5, 2026:
3dcc3c2d, “Restrict control transfers to standard requests and protected
classes”
(whatwg/usb#270), merged by
reillyeon. That change is worth a separate note because it constrains what
controlTransferIn/controlTransferOut can legally do, and the ADB daemon
protocol depends heavily on vendor-specific control transfers — see the
aoaHidRegister snippet above, which is exactly a vendor request type. If
Chrome 158’s control-transfer validation is stricter than what the ADB
daemon protocol needs, that is a breakage for AOA, and it is the kind of
thing worth testing on a real device rather than reasoning about from the
spec.

The WebUSB forget() / close() / disconnect distinction did not
change.
The Nov 4 post covered Chrome 157’s WebUSB disconnect-semantics
change in detail. That analysis still holds; nothing in the last 24 hours
revised it.

No new WebUSB release note or protected-class change. Chrome 158’s
release notes contain no WebUSB entry at all. The API’s only appearance in
158 is by association: WebHID lands on Android alongside it.

What this means for webadb.online

No code changes today, and we are not going to manufacture some. But three
things from this release are worth putting on the record.

First, the Android channel lag is the real story. Chrome 158 has been on
desktop early-stable since November 4 and reaches Android stable today, on a
rollout that is explicitly “a small percentage of users”. The 158 feature set
— SSM, WebHID on Android, the WebTransport reliability work — will be on
Android devices for weeks before it is broadly available. Any conclusion
about Direct Sockets or WebHID on Android drawn from desktop Chrome is
drawing from a build most Android users do not have yet.

Second, Direct Sockets’ value to this ecosystem is now clearer than it has
ever been, and it is still gated behind Isolated Web Apps.
SSM’s promise —
receive only from the specific source you named — is precisely the property
you want when reaching an adbd instance on a shared LAN. But the
IsolatedContext requirement on MulticastController means a plain
webadb-hosted page cannot use it, and the local-network permission policy
from Chrome 151 governs whether an IWA may even try. That is a decision for
the platform, not for us, and the honest position is to keep the current
WebUSB-primary, HTTP-fallback transport matrix while tracking the IWA story.

Third, exportKeyingMaterial is a template. Chromium can now derive
per-session cryptographic material bound to an established session, with
distinct values per logical session even when they share an underlying
connection. The ADB daemon protocol multiplexes several logical connections
over one transport and identifies them by an id pair. Per-session keying
material derived by both ends would be a stronger binding than the id pair
alone. We are not going to propose a protocol change to the ADB daemon from
this blog, but the primitive now exists in a shipping browser API, and it is
worth remembering the next time someone asks why an ADB connection over a
shared transport is trusted at all.

Concretely, the next things we intend to verify on a real device are
whether Chrome 158’s tightened control-transfer validation breaks the
vendor-specific control transfers in the AOA path, and whether the WebUSB
disconnect semantics from Chrome 157 behave identically on the Android
build. Both are device tests, not browser tests, and both are the kind of
question that takes five minutes with a phone plugged in and an hour of
speculation without one.

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

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

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

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

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

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

What “isolated” actually means

crossOriginIsolated is the property. When it’s true:

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

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

The headers, where they live

next.config.mjs:

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

Two policies in play:

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

The GA4 problem

GA4 is loaded with this snippet:

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

Two cross-origin requests happen:

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

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

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

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

Why not credentialless

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

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

We tried this in staging and saw two regressions:

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

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

The dev-server gotcha

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

Verifying isolation is on

Three things to check after any change:

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

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

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

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

What isolation enables on webadb

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

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

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

How webadb.online turns your browser tab into an ADB client

Open webadb.online in Chrome, hit Connect device, pick your phone in
the picker, and a seconds later a >_ shell is running in your browser —
no driver install, no daemon, no native binary. There is no server in the
loop. USB traffic never leaves your machine. This post walks through how
that actually works, end to end: from the moment Chrome renders the
device picker to the moment shell.exec("ls") returns text in your
terminal pane.

The three actors

The whole stack has three components that need to agree on a wire
protocol:

Component Runs in Talks to
Chromium browser your machine Android device via USB
webadb.online a Cloudflare Pages static bundle Chromium (your tab)
ya-webadb bundled in webadb.online (@yume-chan/adb, @yume-chan/adb-daemon-webusb) Chromium → device

ya-webadb is the TypeScript ADB implementation that makes the browser
side possible. It reimplements the ADB protocol on top of a
UsbConnectionInterface that you wire up — for web, that’s the
WebUSB-backed WebUsbDaemonConnection from the -webusb companion
package. We don’t fork ya-webadb; we just import it and feed it the
device the user picked.

Step 1 — the device picker

The whole flow starts with a single line in lib/use-connect-actions.ts:

1
const device = await AdbDaemonWebUsbDeviceManager.requestDevice();

That call hands control to Chrome, which renders the native USB device
picker (anchored to the top-left of the page on macOS, top-center on
Windows). Chrome reads every USB device the host kernel knows about,
filters the ones exposing the ADB interface (vendor 0x18d1 /
0x04e8 / 0x12d1 / …), and shows just those.

Once you pick one, Chrome returns a USBDevice handle. Your
browser tab, not webadb.online, owns that handle.
From this point on,
no other browser tab and no host-side adb daemon can talk to the
phone — the kernel driver is bound to whichever process opened the
device. If you try adb devices on the host while a webadb.online
session is live, you’ll see no devices. This isn’t a bug, it’s how USB
works.

USBDevice is then passed to WebUsbDaemonConnection, which builds
the ADB daemon transport around it.

Step 2 — the ADB handshake

ADB is a length-prefixed binary protocol over a bulk USB endpoint.
Every “connection” is a stream of framed packets; each packet has a
24-byte header (command, arg0, arg1, payload length, magic, checksum)
followed by a payload. ADB version, max payload, banner, etc. are
exchanged in plain ASCII during OPEN.

ya-webadb implements the entire protocol in pure TypeScript. Its
AdbDaemonConnection consumes the framed stream from WebUSB, dispatches
by command, and gives you back Adb objects that wrap sync / async /
shell / file services.

Our lib/adb-client.ts is a thin wrapper around that. It owns:

  • the Adb object (one per session)
  • a disposers array so the React effect cleanup can call .close()
    on the connection when the user disconnects
  • a tiny event bus that the React layer subscribes to

The Adb object itself exposes typed services — AdbSync, AdbShell,
AdbFile, AdbReverse, AdbForward, AdbTcp, AdbPower —
that’s every subsystem the panels use.

Step 3 — auth (the RSA fingerprint dance)

ADB requires an RSA keypair to authenticate the host. The browser
generates one with crypto.subtle.generateKey on first connect; it
persists in IndexedDB (the webadb-online:credentials database).
Every subsequent connection uses the same key.

On the phone, the first time you connect with a new host key, Android
pops a system dialog asking you to confirm the fingerprint of the host
key. The user’s “Always allow from this computer” check permanently
trusts the key. After that, the browser-side auth is invisible.

This is the part people hit most often. If you tapped Cancel once
and now the panel says “device unauthorized”, the fix is to revoke the
authorization from Developer Options on the phone (USB debugging
settings → Revoke authorizations → re-plug). The browser-side key
stays the same, so you don’t have to re-grant every app — just once
per device.

Step 4 — per-service plumbing

Once Adb.authenticate() returns, the panels open connections to
specific services:

1
2
// from lib/screencast/pipeline.ts
const stream = await adb.shell.raw(`screenrecord ...`);

ADB services are addressed by name. shell:v2:raw:command opens a
shell subprocess. sync: opens the file service (used by File
Manager, APK install). shell:exec:command runs a one-shot exec
without a PTY. Each of these has its own framing — some are
length-prefixed streams, some are bidi, some have their own handshake.

ya-webadb models each service as a class. When a panel calls
adb.shell.raw("..."), ya-webadb:

  1. Opens a new USB bulk endpoint pair (in/out).
  2. Sends OPEN(1, "shell:v2:raw:screenrecord ...") to the daemon.
  3. The daemon forks the subprocess and pipes its stdout to the bulk
    in endpoint of the new connection.
  4. Returns a ReadableStream<Uint8Array> that yields the subprocess
    output as it’s produced.

Step 5 — streaming binary data (the screencast case)

Screencast is the gnarliest user of the protocol because it streams
megabytes per second through a 16-MB-payload USB endpoint, where each
MP4 chunk is its own complete ISO BMFF file. We use shell:v2:raw
specifically because the default shell:exec: protocol caps payloads
at the device-side MAX_PAYLOAD from OPEN (usually 256 KB on modern
phones). The :raw variant skips that aggregation layer and gives us
the raw fd, which is what screenrecord --output - would write to —
except HyperOS won’t let screenrecord write to stdout, so we use the
file-mode trick (writing to /sdcard/webadb-screencast.mp4 and polling
the file via a separate stat / dd shell command — see the
screencast deep-dive for why this works and what edge cases we hit).

Step 6 — disconnect

webadb.online only holds the USB device for the lifetime of the tab.
When you close the tab, hit Disconnect in the top bar, or refresh,
the cleanup effect in use-connect-actions.ts calls adb.close()
which:

  1. Sends CLOSE on every open service.
  2. Calls USBDevice.close() on the WebUSB handle.
  3. Releases the kernel driver back to the host (so adb devices on
    the host works again).

The browser may also revoke the persisted device permission, depending
on the version of Chrome. To re-grant, you click Connect device
again — the picker remembers the last device, so it’s one click, not a
fresh flow.

What this means for the security model

Three properties worth calling out:

  1. Zero server trust. The static bundle never talks to anything
    other than your USB device. The Cloudflare Pages origin only serves
    files. There’s no analytics endpoint, no session server, no debug
    ping. GA4 is loaded with anonymize_ip and respects
    prefers-reduced-motion so it doesn’t change behavior in
    privacy-sensitive contexts.

  2. The USB device grant is the security boundary. WebUSB grants
    are per-origin (scheme + host + port). A different origin can’t
    reuse your grant. That’s why we don’t embed a third-party iframe
    for the ADB handshake even when the temptation is real (CDN
    debugging, support tooling).

  3. The persistent key is local. Your ADB host key is in
    IndexedDB under the webadb.online origin. If you clear site data
    the next connection will produce a new key and the phone will ask
    you to authorize again. There’s no way to “back up” the key
    across browsers; that’s intentional.

What’s in the queue

The deeper-cut topics we’ll cover in separate posts:

  • the moov-at-end / rotating-recorder design in the screencast panel,
    and why we can’t just rely on screenrecord - stdout on modern
    Android
  • cross-origin isolation (COEP require-corp + COOP) and the exact
    sequence of headers that lets us use MediaSource,
    SharedArrayBuffer, and WebUSB in the same page without breaking
    GA4
  • the roadmap: audio capture, app deep links, Scrcpy-level touch
    emulation, and a real installer
  • the macOS Big Sur window chrome — why we did it, what the
    accessibility trade-offs are, and how the dock persists state

If you want to peek at the protocol itself, @yume-chan/adb ships a
debugging dump mode that logs every frame to the console — useful
when you’re trying to figure out what cp -r is doing under the hood.

Inside the webadb screencast panel — streaming a device screen via MSE + fMP4

The screencast panel is the only place on webadb.online where bytes
flow from the device into the browser at the speed of a small video
stream. Everything else — terminal output, logcat, file uploads — is
small text. The screencast pipeline is a few hundred lines of code
that has to deal with three things that are not in any textbook:

  1. screenrecord writes the moov box only at end-of-recording —
    the device-side file is half-finished at every moment during the
    recording. ISO BMFF init segments aren’t optional.
  2. screenrecord - (stdout mode) is broken on HyperOS — the
    kernel returns “Read-only filesystem” when screenrecord tries to
    open stdout. So we have to use the file mode and poll a path on
    /sdcard.
  3. The 4-GB size limit on 32-bit box headers — Xiaomi’s
    screenrecord uses 64-bit largesize boxes for the mdat, which
    need BigInt parsing on the JavaScript side.

This post is the writeup of the design that finally shipped — commit
b8fcb37 on main, after four false starts.

The naive plan (what doesn’t work)

The most straightforward screencast implementation is:

1
2
3
4
const stream = await adb.shell.raw("screenrecord - --time-limit 5");
const chunks: Uint8Array[] = [];
for await (const chunk of stream) chunks.push(chunk);
// stitch chunks, mux into fMP4, appendBuffer to MediaSource

The muxing step is the part mp4-muxer does for you. You push H.264
NALs in, you get fMP4 init + media segments out, you append them to a
SourceBuffer and video.src = MediaSource plays it. This is the
shape of the implementation most scrcpy-web clones use.

Two problems:

  1. screenrecord - fails on Xiaomi / HyperOS / API 36. It writes
    to stdout, then the kernel refuses because stdout isn’t a regular
    file on Android. You get error: cannot open 'w-': Read-only filesystem. You can verify this on your phone with
    adb shell "screenrecord - /sdcard/foo.mp4" — same error.

  2. Even where stdout works, the moov box lives in screenrecord’s
    internal buffer until end-of-recording.
    If you mux from raw
    NALs yourself you don’t need moov (you build your own init
    segment with SPS/PPS extracted from the first IDR). But every
    short-lived recording of 3 seconds means 3 seconds of
    dead video at the start — the mux needs an IDR to extract
    SPS/PPS, and screenrecord emits an IDR every couple of seconds,
    so you usually wait ~2 seconds before the first frame paints.

    The dead-video problem is also why trying to do the obvious
    thing — pipe screenrecord stdout straight into your muxer —
    gives you a panel where the video is permanently ~3 seconds
    behind real time.

What we landed on

Use screenrecord in file mode, then poll the file from a
second adb shell command. Use screenrecord’s own ftyp + moov
boxes verbatim (no custom muxing) and stream them to MediaSource.

This works because screenrecord writes complete, self-contained MP4
files. Every file has ftyp at offset 0, mdat in the middle, and moov
at the end. The moov contains the avcC box which has the SPS/PPS.
The mdat contains only H.264 NALs (no SPS/PPS — those are in avcC).
This is verifiable by pulling a finished recording off the device
and running ffprobe:

1
2
3
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'r3.mp4':
Duration: 00:00:04.96, bitrate: 1185 kb/s
Stream #0:0: Video: h264 (High), 480x1072, 1185 kb/s, 30 fps

…and parsing the mdat’s first NALs with a custom walker that
classifies each by NAL type byte: you see [5, 1, 5, 1] (IDR +
non-IDR) but never a 7 (SPS) or 8 (PPS).

So: device gives us a self-contained MP4; we ship it verbatim to
MSE.

But we have the live-streaming problem — screenrecord takes the full
--time-limit to write moov, and we don’t want to wait 3 seconds
with a black screen.

Rotating short recordings

The fix is rotating recordings:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const CHUNK_SECONDS = 3;
while (!stopRequested) {
await shell.spawn(["rm", "-f", STREAM_PATH]).wait();
const proc = await shell.spawn([
"screenrecord",
"--size", `${w}x${h}`,
"--bit-rate", String(bitrate),
"--time-limit", String(CHUNK_SECONDS),
STREAM_PATH,
]);
// Auto-kill in case screenrecord ignores --time-limit on weird ROMs.
setTimeout(() => proc.kill(), (CHUNK_SECONDS + 1) * 1000);
await sleep(CHUNK_ROTATION_SECONDS * 1000); // CHUNK_SECONDS + 2
proc.kill(); // forces moov flush
await sleep(500);
}

Each iteration produces a complete MP4 file with its own moov. We
use the first chunk’s ftyp + moov as the init segment for the
entire MSE session. Subsequent chunks’ mdat content gets appended
as media segments — their ftyp and moov are discarded.

Why this works:

  • screenrecord uses the same encoder config across runs (same
    resolution, same bitrate, same frame rate), so the SPS/PPS in
    every chunk’s avcC are identical to the first chunk’s.
  • MSE remembers the init segment’s stsd/stts/stsc/stsz/stco tables
    and uses them to interpret subsequent media samples. As long as
    the new mdat bytes correspond to those tables, MSE is happy.

There’s a brief gap between chunks — the kill / rm / re-spawn takes
~500 ms during which the file is empty. The MediaSource stays open
and the buffered video keeps playing back during that gap. The
user sees a brief stutter every ~5 seconds, which is much better
than 3 seconds of black screen at start.

Polling a growing file (the part that took longest)

The naive “poll the device-side file” function does this:

1
2
3
4
// WRONG — DO NOT USE
const stream = await shell.spawn([
"tail", "-c", `+${offset + 1}`, STREAM_PATH,
]);

That looks correct but blocks forever when screenrecord is
actively writing to the file. tail -c +N reads from offset N to
EOF and only terminates on EOF — for a file with an open writer,
EOF never comes, so the process never exits, so
ya-webadb‘s shell.spawn().wait() (which awaits
process.exited) never resolves.

The CDP-attached trace that surfaced this is on file in the
screencast saga — [screencast] screenrecord chunk started was
logged, then zero poll lines, even after 20 seconds. The shell
subprocess was just hanging.

Three iterations to fix:

Attempt What we tried Why it failed
tail -c +N Standard incremental tail Blocks on never-EOF writer
wc -c < file One-shot, returns on EOF Same problem — wc also reads to EOF
dd if=… skip=… count= Read N bytes only count cap was missing initially

The actual working implementation uses two shells in sequence:

1
2
3
4
5
6
7
8
9
10
11
12
13
// size probe — stat() is a syscall, never reads content
const size = await shell.spawn(["stat", "-c", "%s", STREAM_PATH])
.wait().toString();

// bounded read — dd exits after reading `count` bytes
const bytes = await shell.spawn([
"dd", `if=${STREAM_PATH}`,
"bs=1", `skip=${offset}`, `count=${want}`,
]).then(p => {
// pipe stdout through ReadableStream
const reader = (p.stdout as ReadableStream).getReader();
// … collect chunks
});

stat -c %s returns the current file size immediately even if
screenrecord is mid-write — the kernel tracks size on every
write(2) call regardless of whether the process has called
fdatasync(2). And dd exits the moment it’s read count bytes,
which terminates the shell subprocess and resolves .wait().

The 8-MB per-poll cap on want keeps each shell transfer
bounded — if we fall way behind for some reason we never drain
megabytes through a single shell call.

The BigInt detour

The mdat box in screenrecord’s output is huge (hundreds of MB for
a 30-min recording). When the file size crosses 4 GB the box
header switches from a 32-bit size to a 64-bit largesize with a
length-1 sentinel at offset 0–3 and the real size at offset 8–15.

JS Number only has 53-bit mantissa precision. Reading the largesize
as hi * 2^32 + lo silently rounds once hi > 1, which gives you
garbage. CDP traces showed mdat(4557430888798830600 @24) — the
4557430888798830600 is 0x3f3f3f3f3f3f4008, which is actually
uninitialized kernel page bytes (the file was allocated but the
content hadn’t been written yet, so the largesize field reads as
sparse placeholder bytes). Even after the field is properly
initialized, the JS rounding still corrupts large values.

Fix:

1
2
3
4
5
6
7
const big = new DataView(
bytes.buffer, bytes.byteOffset + off + 8, 8
).getBigUint64(0, false);
const MAX = BigInt(Number.MAX_SAFE_INTEGER);
const totalSize = big > MAX
? Number.MAX_SAFE_INTEGER
: Number(big);

The clamp to Number.MAX_SAFE_INTEGER is only a safety net —
no real recording on a phone will hit 8 PB. In practice the BigInt
read gives us the correct size every time.

Walkers, not magic numbers

The mp4 box walker used to be a single-purpose findMdatOffset()
function that returned one offset. As soon as we needed to find
ftyp, moov, and mdat in one pass it became a one-shot walker
that records every top-level box it sees:

1
2
3
4
const walkTopBoxes = (bytes: Uint8Array): Array<{
type: string; headerOffset: number;
contentOffset: number; contentLength: number;
}> | null => { /* … */ }

A generic walker is more code but it’s easier to reason about and
easier to add a new box type to (which we did a few times —
adding ftyp capture for the first chunk’s ftyp box so the init
segment uses screenrecord’s exact ftyp verbatim, including the
right brand string and minor version).

The init / media dispatch

The dispatch logic is the part of the pipeline most likely to
silently misroute bytes:

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
const boxes = walkTopBoxes(bytes);
let newFtyp: Uint8Array | null = null;
let newMdatContent: Uint8Array | null = null;
let newMoov: Uint8Array | null = null;
for (const box of boxes) {
if (box.type === "ftyp" && !ftypBytes) newFtyp = bytes.slice(...);
else if (box.type === "mdat") newMdatContent = bytes.slice(...);
else if (box.type === "moov") newMoov = bytes.slice(...);
}

if (newFtyp) ftypBytes = newFtyp;
if (newMdatContent) pendingMdatContent.push(newMdatContent);

if (newMoov && !initSegmentSent) {
// First moov arrives — send init, then flush pending mdat
initSegmentSent = true;
await sourceOpenPromise;
const codec = extractAvcCodec(bytes, newMoov);
const sb = mediaSource.addSourceBuffer(codec);
await sb.appendBufferAsync(concat(ftypBytes, newMoov));
await sb.appendBufferAsync(concat(...pendingMdatContent));
pendingMdatContent = [];
} else if (initSegmentSent && newMdatContent) {
// Subsequent chunks — just append new mdat content
await sourceBuffer.appendBufferAsync(newMdatContent);
}

Three branches, all named: buffer-only (no moov yet),
first-flush (init + buffered mdat), streaming (subsequent
mdat). The initSegmentSent flag is the line that separates
“we’re buffering” from “we’re streaming”.

What would change for audio

screenrecord v3 on AOSP can capture mic audio with
--audio-source MIC, but Xiaomi/HyperOS strips that flag. We
don’t have an audio path yet. The cleanest fix when it ships is
to mux the audio track into the same fMP4 init segment — the
codec extraction in extractAvcCodec becomes
extractAvcAndAudioCodecs returning video/mp4; codecs="avc1.X, mp4a.40.2" and the rest of the pipeline is unchanged. Pulled
into a separate post when we have hardware that supports it.

Where to look in the code

  • lib/screencast/pipeline.ts — the whole pipeline (~700 lines)
  • lib/screencast/types.ts — the codec + progress message types
  • components/ScreencastPanel.tsx — the React panel that owns
    the video element and dispatches start/stop

A future post will dive into the cross-origin isolation setup
(COEP / COOP / CORP) that makes MediaSource and
SharedArrayBuffer available without breaking GA4 — that’s a
whole separate can of worms.

ya-webadb closes its longest-running quirk thread, and the fix is API-level branching

On 2026-10-30 the ya-webadb maintainer closed two long-dormant issues with
the same one-word verdict, “Close as inactive”:
PR #849 (the
multi-device getDeviceFeatures workaround, which the
2026-10-31 post
already covered) and
issue #840, “Standardize
behaviour across android versions”. Issue #840 is the more interesting
close, because the thread it ends is a seven-month-long field report about
Android-version-specific behavior — the exact class of problem that any
ADB-in-browser client eventually runs into.

The reporter’s setup: a web app that worked on an Android 14 device, and
then failed on a OnePlus 6T running Android 11, where even
adb.createSocket("tcp:8080") returned a CLSE packet and the library
surfaced it as Socket open failed. The maintainer’s reply is the part
worth studying, because it answers the reporter’s actual question — “how do
I handle device/OS-level quirks?” — with a taxonomy rather than a patch.

The answer, in the maintainer’s own framing: core adb.* features over
the daemon transport have almost no quirks, server transport has quirks
that depend on the ADB server version, and shell commands have quirks
that depend on the Android version. That last category is where the
per-version branching actually lives in the code, and where webadb.online
— which shells out to pm and am by hand rather than going through the
library’s android-bin wrappers — has a gap.

This post walks through the two-tier mechanism the library uses to
handle version skew, what the Socket open failed path in the packet
dispatcher actually means, and why webadb.online’s current approach to
pm/am is exactly the thing that would break on an Android 7-era
device.

The taxonomy: three transports, three kinds of skew

The maintainer’s reply to #840 lays out the shape of the problem in one
paragraph:

Core features (adb.*) in daemon transport (AdbDaemonTransport) have
almost no quirks. Server transport has quirks depending on server
version. Shell commands have quirks depending on Android versions.

Three layers, three independent sources of version skew, and the fix for
each is different because the fallback mechanism is different.

Daemon transport (AdbDaemonTransport) is the direct WebUSB path
that webadb.online uses. It speaks the ADB wire protocol straight to
adbd on the device — CNXN, AUTH, OPEN, WRTE, OKAY, CLSE — with no
host-side ADB server in the middle. The daemon on the device is
reasonably consistent across versions, so this layer is stable.

Server transport (AdbServerClient) is the path that talks to a
host adb server over TCP. That server is a separate piece of software
with its own version number, and the protocol it speaks has changed over
time. This is where #849 lived: getDeviceFeatures() sends a
device-context switch followed by host:features, and the host service
does not inherit the device context from the switch — so with more than
one device attached, the server rejects the request with "more than one device/emulator".

Shell commands are the third layer. Once you send shell:... to the
device, the command is interpreted by whatever binary is on the device’s
PATH — and Android’s versions of pm, am, cmd, settings, and
friends are not stable across releases. This is the layer where a
browser-based client hits the most surprises, because the client has no
way to know what the device’s userspace looks like until it asks.

Tier one: feature negotiation, not version numbers

Before version-specific branches come into play, the library has a
mechanism that needs no guessing: the device advertises its capabilities
in its feature string, and the library reads them.

libraries/adb/src/features-value.ts defines the constants, and the
ordering comment points at the authoritative source:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// The order follows
// https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/adb/transport.cpp;l=81;drc=2d3e62c2af54a3e8f8803ea10492e63b8dfe709f

export const Shell2 = "shell_v2";
export const Cmd = "cmd";
export const Stat2 = "stat_v2";
export const Ls2 = "ls_v2";
export const FixedPushMkdir = "fixed_push_mkdir";
export const Abb = "abb";
export const FixedPushSymlinkTimestamp = "fixed_push_symlink_timestamp";
export const AbbExec = "abb_exec";
export const RemountShell = "remount_shell";
export const TrackApp = "track_app";
export const SendReceive2 = "sendrecv_v2";
export const SendReceive2Brotli = "sendrecv_v2_brotli";
export const SendReceive2Lz4 = "sendrecv_v2_lz4";
export const SendReceive2Zstd = "sendrecv_v2_zstd";
export const SendReceive2DryRunSend = "sendrecv_v2_dry_run_send";
export const DelayedAck = "delayed_ack";

The client-side query is adb.canUseFeature(...). This is the mechanism
that lets a browser client avoid hardcoded version tables where the
device will just tell it what it supports.

The Cmd helper in libraries/android-bin/src/cmd/ is where feature
negotiation becomes a transport decision. createNoneProtocol tries
three strategies in order:

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
43
export function createNoneProtocol(
adb: Adb,
fallback?: NonNullable<Cmd.Fallback>,
): Cmd.NoneProtocolService | undefined {
if (adb.canUseFeature(AdbFeature.AbbExec)) {
return {
mode: Cmd.Mode.Abb,
spawn: adbNoneProtocolSpawner(async (command, signal) => {
const service = serializeAbbService("abb_exec", command);
const socket = await adb.createSocket(service);
return new AdbNoneProtocolProcessImpl(socket, signal);
}),
};
}

if (adb.canUseFeature(AdbFeature.Cmd)) {
return {
mode: Cmd.Mode.Cmd,
spawn: adbNoneProtocolSpawner(async (command, signal) => {
checkCommand(command);

const newCommand = command.map(escapeArg);
newCommand.unshift("cmd");
return adb.subprocess.noneProtocol.spawn(newCommand, signal);
}),
};
}

if (fallback) {
return {
mode: Cmd.Mode.Fallback,
spawn: adbNoneProtocolSpawner(async (command, signal) => {
checkCommand(command);

const newCommand = command.map(escapeArg);
newCommand[0] = resolveFallback(fallback, command[0]!);
return adb.subprocess.noneProtocol.spawn(newCommand, signal);
}),
};
}

return undefined;
}

Three tiers, and the order matters:

  1. abb_exec — the modern, most efficient path. The command goes
    over a dedicated abb_exec: socket rather than through a shell, with
    \0 as the argument separator so arguments can contain spaces:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    export function serializeAbbService(
    prefix: string,
    command: readonly string[],
    ): string {
    checkCommand(command);

    // `abb` mode uses `\0` as the separator, allowing space in arguments.
    // The last `\0` is required for older versions of `adb`.
    return `${prefix}:${command.join("\0")}\0`;
    }

    Note the trailing \0 — “required for older versions of adb“. That
    is a version quirk baked into a single character, and it is the kind
    of thing that only shows up on old devices.

  2. cmd — the cmd <service> <args> dispatcher available from
    Android 7 onward. This is the tier that replaced shelling out to the
    standalone pm/am binaries.

  3. Fallback — a caller-supplied mapping from service name to
    standalone binary name. This is the pre-Android-7 path where you run
    pm directly instead of cmd package.

The fallback parameter can be a string, a record, or a function, and
resolveFallback handles all three shapes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function resolveFallback(
fallback: Cmd.Fallback,
command: string,
): string {
if (typeof fallback === "function") {
fallback = fallback(command);
} else if (typeof fallback === "object" && fallback !== null) {
fallback = fallback[command];
}

if (!fallback) {
throw new Error(`No fallback configured for command "${command}"`);
}

return fallback;
}

So the library has a feature-flag tier for “how do I invoke this
service” and a version-number tier for “which subcommand does this
service understand.” Those are different problems and need different
answers.

Tier two: the API-level branch, and why cmd package path is special

Feature negotiation cannot solve every problem, because some breakage is
within a feature. cmd exists on Android 7 and later, and the cmd
subcommand set differs per service. The library handles this with an
explicit API-level check in the android-bin wrappers.

The clearest example is PackageManager.getPath in
libraries/android-bin/src/pm.ts:

1
2
3
4
5
6
7
8
9
10
11
12
const command = [PackageManager.ServiceName, "path"];

// Android 7 and 8 support `cmd package` but not `cmd package path` command
let process: AdbNoneProtocolProcess;
if (this.#apiLevel !== undefined && this.#apiLevel <= 27) {
command[0] = PackageManager.CommandName;
process = await this.#adb.subprocess.noneProtocol.spawn(
command.map(escapeArg),
);
} else {
process = await this.#cmd.spawn(command);
}

Read the comment carefully, because the situation is counterintuitive.
On Android 7 and 8 (API 24–27), cmd package works — the service
exists — but the path subcommand is missing from it. So the fix is to
fall back to the older standalone binary, swapping the service name:

1
2
static readonly ServiceName = "package";
static readonly CommandName = "pm";

command[0] = PackageManager.CommandName turns ["package", "path"]
into ["pm", "path"], and the spawn goes through
adb.subprocess.noneProtocol.spawn instead of the cmd wrapper. The
result on the wire is a plain pm path <package> invocation.

The ActivityManager wrapper in libraries/android-bin/src/am.ts does
the same thing at a different threshold:

1
2
3
4
5
6
7
8
9
10
// Android 7 supports `cmd activity` but not `cmd activity start` command
let process: AdbNoneProtocolProcess;
if (this.#apiLevel !== undefined && this.#apiLevel <= 25) {
command[0] = ActivityManager.CommandName;
process = await this.#adb.subprocess.noneProtocol.spawn(
command.map(escapeArg),
);
} else {
process = await this.#cmd.spawn(command);
}

API 25 is Android 7.0; API 27 is Android 8.1. So cmd activity start
was added in Android 8, while cmd package path was added in Android 9.
The two services grew their subcommand sets at different times, and the
library tracks each one separately.

There is a third variant worth calling out, because it is not a
“command doesn’t exist” problem but an “output goes to the wrong stream”
problem. In pm.ts‘s sessionCommit:

1
2
3
4
5
6
7
8
9
10
11
12
// Android 7 did support `cmd package install-commit` command,
// but it wrote the "Success" message to an incorrect output stream,
// causing `checkResult` to fail with an empty message
let process: AdbNoneProtocolProcess;
if (this.#apiLevel !== undefined && this.#apiLevel <= 25) {
command[0] = PackageManager.CommandName;
process = await this.#adb.subprocess.noneProtocol.spawn(
command.map(escapeArg),
);
} else {
process = await this.#cmd.spawn(command);
}

checkResult is strict about this:

1
2
3
4
5
6
7
8
9
10
async checkResult(stream: ReadableStream<Uint8Array>) {
const output = await stream
.pipeThrough(new TextDecoderStream())
.pipeThrough(new ConcatStringStream())
.then((output) => output.trim());

if (!output.startsWith("Success")) {
throw new Error(output);
}
}

output.startsWith("Success") — so on Android 7, where the message
arrives on stderr instead of stdout, checkResult sees an empty string
and throws new Error(""). A user seeing that would reasonably file it
as a webadb bug. The fix is invisible from the outside: the same binary,
spawned through a different transport, whose output lands on the stream
the library is actually reading.

Note also the apiLevel !== undefined guard on every branch. If the
caller does not supply an API level, the library does not guess — it
uses the modern cmd path and lets the device decide. That is a
deliberate design choice: a wrong guess is worse than a device that
answers “unknown command.”

Where the API level comes from

The apiLevel is a constructor argument, not something the library
detects:

1
2
3
4
5
constructor(adb: Adb, apiLevel?: number) {
this.#adb = adb;
this.#apiLevel = apiLevel;
this.#cmd = Cmd.createNoneProtocol(adb, PackageManager.CommandName);
}

And a grep across libraries/ for ro.build.version returns nothing —
the library never reads getprop ro.build.version.sdk itself. The caller
supplies it if they want version-specific behavior, and undefined (the
default) means “use the modern path and hope for the best.”

For webadb.online this is the concrete gap. Our lib/adb-client.ts
invokes pm and am directly:

1
2
3
4
5
6
7
8
async listInstalledPackages(opts?: { includeSystem?: boolean }): Promise<PackageInfo[]> {
const s = this.requireSession();
const includeSystem = opts?.includeSystem !== false;
const args = ["pm", "list", "packages", "-f"];
if (!includeSystem) args.push("-3");
const out = await spawnText(s.adb, args);
return parsePackageList(out);
}

and

1
2
3
4
5
6
7
8
9
10
11
12
async launchPackage(packageName: string): Promise<string> {
const s = this.requireSession();
const resolved = await spawnText(s.adb, [
"cmd",
"package",
"resolve-activity",
"--brief",
packageName,
]);
...
return spawnText(s.adb, ["am", "start", "-n", activity]);
}

Two different invocation styles in the same file, chosen per call site
rather than per device: launchPackage uses cmd package resolve-activity (requiring the cmd feature, i.e. Android 7+), while
listInstalledPackages uses the standalone pm binary (working
everywhere but older than the cmd path would prefer). Neither checks
the device’s API level, and neither knows that cmd package path does
not exist before Android 9.

Our package.json pins @yume-chan/adb at ^3.0.0-beta.1 and does not
depend on @yume-chan/android-bin at all — the android-bin package
that contains pm.ts and am.ts is not in our dependency tree. So the
version-aware wrappers are simply not available to us today.

The Socket open failed path, for completeness

The reporter’s Android 11 symptom — createSocket() returning
Socket open failed — comes from a specific place in the ADB daemon
packet dispatcher, libraries/adb/src/daemon/dispatcher.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// If the socket is still pending
if (
packet.arg0 === 0 &&
this.#initializers.reject(
packet.arg1,
new Error("Socket open failed"),
)
) {
// Device failed to create the socket
// (unknown service string, failed to execute command, etc.)
// it doesn't break the connection,
// so only reject the socket creation promise,
// don't throw an error here.
return;
}

The semantics are worth understanding precisely, because they explain
why the failure looks like a bug and is not one. The ADB daemon replies
to an OPEN request with either OKAY (success) or CLSE (failure).
On CLSE, arg0 is 0 if the open failed, and the daemon responds
with a “close” of the local socket ID that was never successfully
opened.

The comment above that block spells out the historical wrinkle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// From https://android.googlesource.com/platform/packages/modules/adb/+/65d18e2c1cc48b585811954892311b28a4c3d188/adb.cpp#459
/* According to protocol.txt, p->msg.arg0 might be 0 to indicate
* a failed OPEN only. However, due to a bug in previous ADB
* versions, CLOSE(0, remote-id, "") was also used for normal
* CLOSE() operations.
*/

// Ignore `arg0` and search for the socket
const socket = this.#sockets.get(packet.arg1);
if (socket) {
await socket.close();
socket.dispose();
this.#sockets.delete(packet.arg1);
return;
}

Old ADB versions sent CLSE(0, ...) for normal closes too, so a CLSE
packet with arg0 === 0 is ambiguous between “open failed” and “close
acknowledged”. The library disambiguates by checking whether a socket
with that local ID is still pending in #initializers: if it is, the
open failed; if a socket object exists in #sockets, it was a close
confirmation.

Critically, the failure is scoped. A failed OPEN does not tear down
the ADB connection — only the socket-creation promise rejects. The
comment says so explicitly, and it matters: a browser client that treats
one failed OPEN as a fatal transport error will disconnect a perfectly
healthy device.

Now, why did the reporter’s Android 11 device reject OPEN("tcp:8080")
while the same device accepted toybox nc 127.0.0.1 8080 over a shell
socket? The answer is that these are two different ADB services with
different implementations:

  • OPEN("tcp:8080") asks adbd to open a TCP connection from the
    device
    to 127.0.0.1:8080, using adbd‘s own socket-opening code.
  • OPEN("shell:...") asks adbd to spawn a subprocess.

The tcp: service in adbd has historically been implemented with
local_connect and its own path/permission handling, and its behavior
varies across Android versions and OEM forks. The maintainer’s reaction
was appropriately skeptical — “That’s indeed really wired” — and he asked
the reporter to verify against the official Google adb binary with
adb forward tcp:8080 tcp:8080. The reporter went quiet for three
months and the issue was closed.

That is the honest state of it: an unreproduced, now-closed issue. The
value in it for the ecosystem is not the bug, but the maintainer’s
taxonomy — which is the part that generalizes.

What this means for webadb.online

Two concrete takeaways, one about our code and one about our
dependencies.

Our pm/am calls are the fragile part, not the ADB transport. The
library’s own framing is that daemon-transport adb.* features have
almost no quirks. Everything version-sensitive in our app lives one level
up, in the shell commands we hand-roll. Our launchPackage uses
cmd package resolve-activity, which requires the cmd feature
(Android 7+) and — on older Android — a cmd package service whose
subcommand set differs from modern. Our listInstalledPackages uses
the standalone pm binary, which is the more compatible choice but
not the one the library would make on a modern device.

A concrete, low-risk improvement: read the device’s API level once at
connect time (getprop ro.build.version.sdk over the shell we already
have), and use it to pick between the cmd and standalone-binary forms
per call site. That mirrors what android-bin does, at a fraction of the
effort, and it makes our App Manager panel behave predictably on
Android 7/8 devices instead of failing with an opaque “unknown command”
string from the device.

@yume-chan/android-bin is the package that would fix this properly,
and it is a dependency we do not have.
It carries the pm.ts/am.ts
wrappers with their API-level branches, the Cmd service with its
three-tier fallback (ABB → cmd → standalone binary), and the
checkResult/serializeAbbService helpers that encode years of
device-version archaeology. Adopting it would mean replacing our
hand-rolled pm/am invocations in lib/adb-client.ts with typed
method calls — packageManager.getPath(pkg) instead of
spawnText(s.adb, ["pm", "path", pkg]) — and passing the detected API
level into the constructors so the branches actually engage.

That is a real refactor rather than a patch: our lib/adb-client.ts
parses pm list packages -f output with a hand-written
parsePackageList, and android-bin has its own parser with its own
format assumptions. It is not a change to make casually. But the
direction is clear, and the upstream library is not going to absorb the
problem for us — issue #840 sat open for seven months because it is
inherently a per-integration problem.

The Chrome Direct Sockets check was skipped this week — that source is
only scanned on Mondays — so this post covers upstream ya-webadb issue
activity only.

webadb.online roadmap — what's next, what's blocked, what we need

This is the post I’d want to read if I were considering using
webadb.online for something serious and wanted to know what’s
actually in the queue. Concrete features, ordered by where they
are in the development cycle, with the blockers called out.

We’re at v0.1.0, with twelve panels and ~5k LOC of TypeScript.
Things that work, work well — the screencast saga is the
long-running one and even that has stabilized. The notes below
are about the gaps.

Now (the next 6 weeks)

Screencast audio

screenrecord --audio-source MIC works on AOSP but Xiaomi /
HyperOS strips the flag. We’ve confirmed via adb shell screenrecord --help that the option is silently dropped before
the binary starts, so we can’t even pass it through. The two
paths forward:

  1. Capture audio on the device via a different route (e.g. a
    tiny helper app we side-load) and mux it into the fMP4 init
    segment at the browser side.
  2. Wait for Xiaomi to ship unstripped screenrecord.

The fMP4 muxing is already in the design — extractAvcCodec
becomes extractAvcAndAudioCodecs and the
addSourceBuffer(codecs) call gets the combined codec string
(video/mp4; codecs="avc1.X,mp4a.40.2"). Maybe 40 lines of
change once we have a source.

Clipboard panel for bidirectional text + image

Today the clipboard panel reads text from the device clipboard.
Bidirectional text works via the cmd clipboard set-text
service. Images don’t work because the device-side clipboard
service only carries text; images on Android are stored
per-app, not in a system-wide clipboard, until Android 14’s
“default clipboard” opt-in lands.

Scrcpy-level touch

The screencast panel currently has no input — it’s strictly
view-only. Touch is the obvious next addition. The
implementation route is adb shell input touchscreen tap x y,
which is on-device latency of 100–200 ms. Scrcpy does better
with a custom InputManager service but that’s not accessible
without root.

For mouse: same primitive, different event type (tap vs
swipe). We can ship click + drag + scroll with no
architectural change. The harder half is multi-touch (pinch
to zoom) which needs input touchscreen calls in a specific
sequence with a tight timing budget.

Recursive filename search with a regex, plus content grep on
text files (capped at ~10MB per file so we don’t blow the
device’s RAM). The wiring is there — AdbSync.read() streams
chunks — we just haven’t built the UI for it.

Next (6–16 weeks)

Multi-device switching

Today the topbar shows one device. If you have two phones
plugged in via a hub, you connect to one, then disconnect,
then connect to the other. There’s no parallel-session model.
Adding it would mean:

  • An AdbConnection[] instead of Adb | null in the React
    store.
  • Each panel takes a deviceId prop and looks up the right
    connection.
  • The screencast pipeline is the gnarliest port — its
    spawn / kill / file-poll loop has to be per-device.

We’d want this for power users. It’s not in the queue for the
first six weeks because it’s the kind of feature that
needs careful state-management work to avoid cross-device
pollution.

adb shell am start -W -a android.intent.action.VIEW -d URI
will deep-link into an installed app. Adding a panel that lets
you bookmark a list of deep links (per app) and fire them with
a click is two days of work. Useful for QA, useful for
presentations.

Persistent host key backup / restore

The browser’s ADB keypair lives in IndexedDB. There’s no way
to export it. Adding an export-to-PEM button is maybe 50 lines.
Restoring from PEM is another 50. Both useful for people who
have multiple browsers and want to skip the phone’s “Allow USB
debugging?” dialog when they switch.

Later (next 6+ months)

Installer / launcher

A package that bundles webadb.online with the ability to detect
when the user has Chrome installed and one-click register a
chrome://webadb/ shortcut. Outside the scope of a Cloudflare
Pages static bundle, but doable as a small Electron or
Tauri-style binary that wraps the same bundle.

WebRTC tunneling for low-latency screencast

adb reverse is fine for shell and other bidirectional
streams, but the screencast path polls a file on the device.
A WebRTC bridge would let us send H.264 over a peer connection
to the browser and skip the polling round trip entirely.
Latency drop: ~150ms. Effort: large — needs a small native
helper on the device that owns the encoder.

Account sync

Multi-device workflow needs an opt-in account layer to sync
saved layouts, app shortcuts, the persistent ADB key, and the
last device used. The data is small enough to fit in any
cloud provider; we’d never see the content of your shell
sessions, only metadata.

We’re deliberately not building this until there’s actual
demand. Account systems are expensive to design well and easy
to design badly.

What’s blocked (and why)

Wireless ADB out of the box

The Wi-Fi ADB panel exists and works for devices that
already have wireless debugging enabled. We can’t toggle
wireless debugging on by default because the API to do so
(adb shell settings put global adb_wifi_enabled 1) is gated
behind either root or a specific permission that Xiaomi doesn’t
grant over USB. This isn’t a web limit — it’s an Android one.

Multi-touch input

As noted under Scrcpy-level touch — needs InputManager service
which needs root. There might be a way with vendor-specific
utilities (Xiaomi has cmd input extensions) but we haven’t
checked every ROM variant. Will dig into it once a Xiaomi
engineering contact surfaces.

adb backup / adb restore

Backup is a 4-year-deprecated shell protocol that Google
keeps around but won’t fix. Most modern apps refuse to
participate (you have to opt-in via android:allowBackup="false"
which many devs do). The wire format is documented but the
end-to-end is broken enough that we’d be building a
compatibility shim. Not in the queue.

What we need from users

If you’re using webadb daily and want something done, the most
useful things you can do are:

  1. Report what device + OS combo you’re on when something
    doesn’t work. The screencast saga was unblocked by a single
    adb shell screenrecord --help output from a Xiaomi
    user.
  2. Open issues for actual blockers, not feature requests.
    “I want my layout to persist across sessions” we already
    built; “I want feature X” is on the roadmap. The first type
    of issue gets a fix in days, the second waits for someone
    to want it hard enough to build.
  3. Don’t ask for cloud sync. We won’t do it. If you want
    it, fork and build it. Sync systems are where privacy
    disasters happen and the security model of webadb
    specifically avoids server state.

Where to follow along

The release notes go in this blog (you’ll see them tagged
release-notes). The detailed engineering writeups are
tagged deep-dive. The roadmap itself is just this post,
updated quarterly.

If you’ve read this far, the next post in the series is
The macOS Big Sur chrome — how the window manager, the
dock, the draggable + maximizable desktop windows, and the
state persistence work, and why we picked that aesthetic
over a more “PWA-y” mobile-shell look.