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 | write(chunk, controller) { |
and Consumable.prototype.tryConsume inlibraries/stream-extra/src/consumable.ts does this:
1 | tryConsume<U>(callback: (value: T) => U) { |
Read that rejection branch carefully: when the sink’s write callback
rejects, tryConsume does two things with the same error e. It callsthis.#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 of59e95a0e, this:
1 | static async write<T>( |
Note the ordering. await writer.write(consumable) comes first. If the sink
rejects, that await throws, and control flow leaves the function beforeawait 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 | try { |
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—sendPacketduring
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.WritableStreamorMaybeConsumable.WritableStream(for example, writing throughScrcpyControlMessageWriterafter the underlying socket has been closed by
the remote peer) crashes the process withunhandledRejectioneven if the
caller wrapswrite()intry / catchor 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 | static async write<T>( |
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 toconsumable.consumedimmediately, then awaitwriter.write
before awaitingconsumable.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 | async write(chunk) { |
rejects consumed with "consumed error" first, and rejectswriter.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 toconsumable.consumedwhile preserving sequentialawait writer.write(consumable)andawait consumable.consumed).
and opened #878 fifty seconds later.
The fix that survived
PR #878, commit3e84659c, is the whole of it:
1 | const consumable = new Consumable(value); |
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.writerejects → the caller sees the write error, andconsumed‘s rejection has already been marked handled; - if
writer.writeresolves butconsumedrejects → 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 | it("WritableStream.write should not emit unhandledRejection when sink write rejects", async () => { |
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 | it("WritableStream.write should wait for writer.write and prefer its rejection over consumed rejection", async () => { |
Two assertions do the work here. error.message === "writer error" checks
that the write error wins over the earlier consumed error. AndwriteFinished === 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 backsMaybeConsumable.WritableStream (vialibraries/stream-extra/src/maybe-consumable/writable.ts), so the fix
covers both variants — the CodeRabbit “Linked Issues check” spelled this
out:
This shared
ConsumableWritableStreamimplementation covers the linkedConsumable.WritableStreamandMaybeConsumable.WritableStreamscope.
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 fromyume-chan. - No release. The latest published
@yume-chan/stream-extrais still3.0.0-beta.4(published 2026-10-09), and3.0.0-beta.4predates this
fix entirely.npm view @yume-chan/stream-extra dist-tagsstill reportslatest: 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 havepromiseA and promiseB where failing A implies failing B, attaching
the handler to B after awaiting A is a latent bug — the rejection ofB 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 aWritableStream<Consumable<AdbPacketInit>> — the exact type whoseConsumable.WritableStream.write helper is being fixed:
1 | const serialize = new AdbPacketSerializeStream(); |
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 consoleUnhandledPromiseRejection 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, inConsumableWritableStream.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.