What version of workerd are you using?
1.20260722.1 (also present in 1.20260423.1 and 1.20260317.1 — not bisected further; the code path involved, src/workerd/api/hyperdrive.c++, has not materially changed across these).
Binaries tested (all from @cloudflare/workerd-darwin-arm64 / the workerd npm package, as vendored by workers-sdk's miniflare):
$ ./workerd --version
workerd 2026-07-22
Platform: macOS 26 (Darwin 25.5.0), arm64.
What happened?
workerd's hyperdrive binding (Hyperdrive::connect() in src/workerd/api/hyperdrive.c++) assumes its designator always resolves to an external (raw TCP) service, because that is the only configuration Wrangler/Hyperdrive tooling has ever produced. If the designator instead resolves to a Worker service (e.g. a config typo, or a deliberate experiment binding a Hyperdrive-shaped binding at another Worker), calling .connect() on the binding crashes the entire workerd process with SIGSEGV instead of throwing a JS-catchable error.
This is unsupported/unintended usage, but a runtime should never segfault in response to a misconfiguration reachable from a config file + JS binding call — it should throw (e.g. TypeError) like the analogous "worker doesn't export a connect() handler" case already does correctly (see below).
Discovery context
Found while locally prototyping a Hyperdrive-over-remote-binding relay for local dev (routing a hyperdrive binding's designator at another Worker service instead of external, as a workaround/experiment), as a follow-up to cloudflare/workers-sdk#14710 and #14712. Not encountered via any production Hyperdrive config; no Hyperdrive config id, account id, or credentials are involved in this reproduction — it is 100% local and self-contained.
What did you expect to happen?
Either:
connect() throws a catchable JS error (e.g. TypeError: Hyperdrive binding designator must be an external service), same as the existing, correctly-handled case where the connect target Worker doesn't export a connect() handler (workerd/api/global-scope.c++:232, Handler does not export a connect() function.); or
- it works, if pointing a Hyperdrive-shaped binding at a Worker service is meant to be a supported extension point.
Either way: no SIGSEGV, and no impact on other requests/isolates sharing the same workerd process.
How can we reproduce it?
This reproduces with plain workerd serve and a hand-written .capnp config — no workers-sdk, wrangler, or miniflare involved, and no real Postgres/MySQL anywhere. The hyperdrive binding's database/user/password/scheme fields are dummy placeholders; they are never actually used before the crash.
Two Workers:
- worker-a: has a
hyperdrive binding named HYPERDRIVE, whose designator is "worker-b" (a Worker service, not external). Its fetch() handler calls env.HYPERDRIVE.connect().
- worker-b: an ordinary Worker that is the (incorrect) target of that designator.
config.capnp
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [
(name = "worker-a", worker = .workerA),
(name = "worker-b", worker = .workerB),
],
sockets = [
( name = "http", address = "*:8080", http = (), service = "worker-a" ),
]
);
const workerA :Workerd.Worker = (
modules = [
(name = "worker", esModule = embed "worker-a.js"),
],
compatibilityDate = "2026-07-01",
compatibilityFlags = ["experimental", "nodejs_compat"],
bindings = [
(
name = "HYPERDRIVE",
hyperdrive = (
# This is the crux of the reproduction: the designator names a Worker
# service ("worker-b"), not an `external` service as Hyperdrive
# bindings normally use in production.
designator = "worker-b",
database = "postgres",
user = "postgres",
password = "postgres",
scheme = "postgresql",
),
),
],
);
const workerB :Workerd.Worker = (
modules = [
(name = "worker", esModule = embed "worker-b-withhandler-nopipe.js"),
],
compatibilityDate = "2026-07-01",
compatibilityFlags = ["experimental", "nodejs_compat"],
);
worker-a.js
export default {
async fetch(request, env, ctx) {
try {
const socket = env.HYPERDRIVE.connect();
await socket.opened;
return new Response("connected: " + JSON.stringify({
readable: !!socket.readable,
writable: !!socket.writable,
}));
} catch (err) {
return new Response("caught error (expected, this is fine): " + err.stack, {
status: 500,
});
}
},
};
worker-b-withhandler-nopipe.js (the crashing target — exports a connect() handler)
export default {
async fetch(request, env, ctx) {
return new Response("target-worker fetch() called");
},
async connect(socket, env, ctx) {
await socket.opened;
// Deliberately do nothing else -- no data is ever piped.
},
};
Run it
workerd serve config.capnp --experimental --verbose
# in another terminal:
curl -v http://localhost:8080/
curl gets back 200 OK with body connected: {"readable":true,"writable":true} — i.e. the Hyperdrive-side JS connect() call appears to succeed and the HTTP response is flushed to the client — but within roughly a second afterward, the whole workerd process dies with SIGSEGV (confirmed reproducible across 3 separate runs, same relative crash-stack shape each time):
*** Received signal #11: Segmentation fault: 11
stack: 1070c3427 1070c3427 1070c3287 1050f444b 1050c7cf7 1050ca227 1070c9ec7 1050ca77b 1070c9d67 1070caa9f 1070c82c7 1070c8cb3 1045b7e97 10712386f 107123b97 107122403 1071221c3 1045a48f3 1809bbdff
A second run (different ASLR slide, same relative offsets between stack entries, confirming the same code path):
*** Received signal #11: Segmentation fault: 11
stack: 10553b427 10553b427 10553b287 10356c44b 10353fcf7 103542227 105541ec7 10354277b 105541d67 105542a9f 1055402c7 105540cb3 102a2fe97 10559b86f 10559bb97 10559a403 10559a1c3 102a1c8f3 1809bbdff
I was not able to symbolicate these addresses in my environment (no task_for_pid/debugger-attach entitlement available, and no crash report was generated by the OS), so I can't point to an exact line with certainty — see "Suspected root cause" below for candidates found by reading the source.
What narrows down the crash location
- The crash does not require any data to flow over the socket. It reproduces identically whether
worker-b's connect() handler pipes the stream (socket.readable.pipeTo(socket.writable)) or does nothing but await socket.opened. So the bug is in the connect handshake completing, not in subsequent stream I/O.
- The crash requires
worker-b to actually accept the CONNECT (i.e. have a connect() handler that runs to the response.accept() point in ServiceWorkerGlobalScope::connect(), src/workerd/api/global-scope.c++:198). If worker-b has no connect() handler at all, there is no crash — instead you get a clean, catchable JS error, exactly as designed:
Received a connect event but we lack a handler. Did you remember to export a connect() function?
workerd/io/io-context.c++:444: info: uncaught exception; exception = workerd/api/global-scope.c++:232: failed: jsg.Error: Handler does not export a connect() function.
workerd/api/hyperdrive.c++:39: warning: failed to connect to local database; e = ... Handler does not export a connect() function.
This confirms the missing-handler path (JSG_FAIL_REQUIRE(Error, "Handler does not export a connect() function.")) is safe, and the crash is specific to the success path where the CONNECT is actually accepted cross-Worker.
- The HTTP response to the original
fetch() request on worker-a is delivered successfully before the crash, meaning the crash happens in background/deferred work — most likely during teardown of the synthetic connect pipe/IoContext(s) involved, not during the initial connect handshake itself.
Suspected root cause (unconfirmed candidates — for triage, not a definitive diagnosis)
I was not able to get a symbolicated backtrace in my environment, so none of these are confirmed; they're what stood out while reading the code paths that Hyperdrive::connect() exercises when its designator is a Worker service, which is a path that (as far as I can tell) has never been exercised in production because Hyperdrive designators are always external.
-
src/workerd/api/hyperdrive.c++:109-115 (Hyperdrive::connectToDb()):
auto connectReq = kj::newHttpClient(*service)->connect(
kj::str(getHost(), ":", getPort()), headers, kj::HttpConnectSettings{});
auto status = co_await connectReq.status;
if (status.statusCode >= 200 && status.statusCode < 300) {
co_return kj::mv(connectReq.connection);
}
kj::newHttpClient(*service) returns a kj::Own<kj::HttpClient> temporary that is never bound to a variable — only the result of ->connect(...) (a ConnectRequest, whose .status is then co_awaited and whose .connection is used) is kept. Compare with the only other in-tree caller of this exact overload, src/workerd/io/worker-interface.c++:118:
return kj::newHttpClient(*workerInterface).attach(kj::mv(workerInterface));
which explicitly keeps the returned kj::Own<kj::HttpClient> alive (and ties the wrapped WorkerInterface's lifetime to it). hyperdrive.c++ does neither. If the specific kj::HttpClient adapter/coroutine machinery involved in bridging a WorkerInterface's (i.e. a Worker's) connect() back through this adapter is not as fully self-contained as the external-service path (which is far better trodden), this early-destroyed temporary is a natural place to look for a dangling reference.
-
src/workerd/io/io-context.c++, IoContext::getSubrequestChannel / getSubrequestNoChecks (~line 1000-1030): builds a stack-local TraceContext tracing for the "hyperdrive_connect" operation name and only attaches it to the returned WorkerInterface if (tracing.isObserved()). Worth checking whether tracing/span state differs meaningfully, and is handled correctly, when the destination channel resolves to a full Worker (with its own isolate lock, IoContext, and tracer) versus a raw external TCP connection (which has none of that).
-
src/workerd/io/worker-entrypoint.c++:619-699 (WorkerEntrypoint::connect) and src/workerd/api/global-scope.c++:185-233 (ServiceWorkerGlobalScope::connect): both appear to assume the caller is genuine CONNECT ingress — i.e. a real listening tcp-type socket accepting an external connection (see the comment at global-scope.c++:216-218: "We set isDefaultFetchPort to false here ... but this is not relevant on the TCP server side"). In this repro, the same code path is instead reached synthetically, as the target of a Hyperdrive .connect() subrequest issued from another Worker's IoContext via the kj::newHttpClient(WorkerInterface&) adapter — i.e. two independent IoContexts (worker-a's and worker-b's) share a synthetic in-process pipe rather than a real accepted socket. An ordering/lifetime assumption around that cross-IoContext teardown (worker-a's request finishing and releasing the Hyperdrive Socket/subrequest channel while worker-b's connect() handler and its IncomingRequest::drain() are still unwinding, or vice versa) seems like a plausible place for a use-after-free.
Additional notes
- This is very likely not reachable from any real production Hyperdrive configuration today — Hyperdrive designators are always
external services provisioned by Cloudflare's control plane. It surfaced only while experimenting with routing a hyperdrive-shaped binding at a Worker service directly.
- Regardless of "is this configuration supported," a config-file-reachable SIGSEGV is a process-wide crash (all in-flight requests/isolates in that
workerd process go down with it), so it seems worth hardening Hyperdrive::connect() / the underlying connect() plumbing to fail gracefully (e.g. reject non-external designators explicitly, or otherwise ensure the same safe-JS-error behavior as the "no connect handler" case) regardless of whether the feature of Hyperdrive-over-Worker-designator is ever supported.
- Full repro files (config + both worker scripts + raw crash logs from two separate runs) are available and can be attached/pasted in full if useful; omitted here only for length.
What version of workerd are you using?
1.20260722.1(also present in1.20260423.1and1.20260317.1— not bisected further; the code path involved,src/workerd/api/hyperdrive.c++, has not materially changed across these).Binaries tested (all from
@cloudflare/workerd-darwin-arm64/ theworkerdnpm package, as vendored byworkers-sdk'sminiflare):Platform: macOS 26 (Darwin 25.5.0), arm64.
What happened?
workerd'shyperdrivebinding (Hyperdrive::connect()insrc/workerd/api/hyperdrive.c++) assumes itsdesignatoralways resolves to anexternal(raw TCP) service, because that is the only configuration Wrangler/Hyperdrive tooling has ever produced. If the designator instead resolves to a Worker service (e.g. a config typo, or a deliberate experiment binding a Hyperdrive-shaped binding at another Worker), calling.connect()on the binding crashes the entireworkerdprocess with SIGSEGV instead of throwing a JS-catchable error.This is unsupported/unintended usage, but a runtime should never segfault in response to a misconfiguration reachable from a config file + JS binding call — it should throw (e.g.
TypeError) like the analogous "worker doesn't export aconnect()handler" case already does correctly (see below).Discovery context
Found while locally prototyping a Hyperdrive-over-remote-binding relay for local dev (routing a
hyperdrivebinding's designator at another Worker service instead ofexternal, as a workaround/experiment), as a follow-up tocloudflare/workers-sdk#14710and#14712. Not encountered via any production Hyperdrive config; no Hyperdrive config id, account id, or credentials are involved in this reproduction — it is 100% local and self-contained.What did you expect to happen?
Either:
connect()throws a catchable JS error (e.g.TypeError: Hyperdrive binding designator must be an external service), same as the existing, correctly-handled case where the connect target Worker doesn't export aconnect()handler (workerd/api/global-scope.c++:232,Handler does not export a connect() function.); orEither way: no SIGSEGV, and no impact on other requests/isolates sharing the same
workerdprocess.How can we reproduce it?
This reproduces with plain
workerd serveand a hand-written.capnpconfig — noworkers-sdk,wrangler, orminiflareinvolved, and no real Postgres/MySQL anywhere. Thehyperdrivebinding'sdatabase/user/password/schemefields are dummy placeholders; they are never actually used before the crash.Two Workers:
hyperdrivebinding namedHYPERDRIVE, whosedesignatoris"worker-b"(a Worker service, notexternal). Itsfetch()handler callsenv.HYPERDRIVE.connect().config.capnpworker-a.jsworker-b-withhandler-nopipe.js(the crashing target — exports aconnect()handler)Run it
workerd serve config.capnp --experimental --verbose # in another terminal: curl -v http://localhost:8080/curlgets back200 OKwith bodyconnected: {"readable":true,"writable":true}— i.e. the Hyperdrive-side JSconnect()call appears to succeed and the HTTP response is flushed to the client — but within roughly a second afterward, the wholeworkerdprocess dies with SIGSEGV (confirmed reproducible across 3 separate runs, same relative crash-stack shape each time):A second run (different ASLR slide, same relative offsets between stack entries, confirming the same code path):
I was not able to symbolicate these addresses in my environment (no
task_for_pid/debugger-attach entitlement available, and no crash report was generated by the OS), so I can't point to an exact line with certainty — see "Suspected root cause" below for candidates found by reading the source.What narrows down the crash location
worker-b'sconnect()handler pipes the stream (socket.readable.pipeTo(socket.writable)) or does nothing butawait socket.opened. So the bug is in the connect handshake completing, not in subsequent stream I/O.worker-bto actually accept the CONNECT (i.e. have aconnect()handler that runs to theresponse.accept()point inServiceWorkerGlobalScope::connect(),src/workerd/api/global-scope.c++:198). Ifworker-bhas noconnect()handler at all, there is no crash — instead you get a clean, catchable JS error, exactly as designed:JSG_FAIL_REQUIRE(Error, "Handler does not export a connect() function.")) is safe, and the crash is specific to the success path where the CONNECT is actually accepted cross-Worker.fetch()request onworker-ais delivered successfully before the crash, meaning the crash happens in background/deferred work — most likely during teardown of the synthetic connect pipe/IoContext(s) involved, not during the initial connect handshake itself.Suspected root cause (unconfirmed candidates — for triage, not a definitive diagnosis)
I was not able to get a symbolicated backtrace in my environment, so none of these are confirmed; they're what stood out while reading the code paths that
Hyperdrive::connect()exercises when its designator is a Worker service, which is a path that (as far as I can tell) has never been exercised in production because Hyperdrive designators are alwaysexternal.src/workerd/api/hyperdrive.c++:109-115(Hyperdrive::connectToDb()):kj::newHttpClient(*service)returns akj::Own<kj::HttpClient>temporary that is never bound to a variable — only the result of->connect(...)(aConnectRequest, whose.statusis thenco_awaited and whose.connectionis used) is kept. Compare with the only other in-tree caller of this exact overload,src/workerd/io/worker-interface.c++:118:return kj::newHttpClient(*workerInterface).attach(kj::mv(workerInterface));which explicitly keeps the returned
kj::Own<kj::HttpClient>alive (and ties the wrappedWorkerInterface's lifetime to it).hyperdrive.c++does neither. If the specifickj::HttpClientadapter/coroutine machinery involved in bridging aWorkerInterface's (i.e. a Worker's)connect()back through this adapter is not as fully self-contained as theexternal-service path (which is far better trodden), this early-destroyed temporary is a natural place to look for a dangling reference.src/workerd/io/io-context.c++,IoContext::getSubrequestChannel/getSubrequestNoChecks(~line 1000-1030): builds a stack-localTraceContext tracingfor the"hyperdrive_connect"operation name and only attaches it to the returnedWorkerInterfaceif (tracing.isObserved()). Worth checking whether tracing/span state differs meaningfully, and is handled correctly, when the destination channel resolves to a full Worker (with its own isolate lock,IoContext, and tracer) versus a raw external TCP connection (which has none of that).src/workerd/io/worker-entrypoint.c++:619-699(WorkerEntrypoint::connect) andsrc/workerd/api/global-scope.c++:185-233(ServiceWorkerGlobalScope::connect): both appear to assume the caller is genuine CONNECT ingress — i.e. a real listeningtcp-type socket accepting an external connection (see the comment atglobal-scope.c++:216-218: "We set isDefaultFetchPort to false here ... but this is not relevant on the TCP server side"). In this repro, the same code path is instead reached synthetically, as the target of a Hyperdrive.connect()subrequest issued from another Worker'sIoContextvia thekj::newHttpClient(WorkerInterface&)adapter — i.e. two independentIoContexts (worker-a's and worker-b's) share a synthetic in-process pipe rather than a real accepted socket. An ordering/lifetime assumption around that cross-IoContextteardown (worker-a's request finishing and releasing the HyperdriveSocket/subrequest channel while worker-b'sconnect()handler and itsIncomingRequest::drain()are still unwinding, or vice versa) seems like a plausible place for a use-after-free.Additional notes
externalservices provisioned by Cloudflare's control plane. It surfaced only while experimenting with routing ahyperdrive-shaped binding at a Worker service directly.workerdprocess go down with it), so it seems worth hardeningHyperdrive::connect()/ the underlyingconnect()plumbing to fail gracefully (e.g. reject non-externaldesignators explicitly, or otherwise ensure the same safe-JS-error behavior as the "no connect handler" case) regardless of whether the feature of Hyperdrive-over-Worker-designator is ever supported.