Skip to content

Give Rust APIs the IoContext of the current request - #7963

Open
bartlomieju wants to merge 14 commits into
mainfrom
biwanczuk/rust-jsg-io-context
Open

bartlomieju wants to merge 14 commits into
mainfrom
biwanczuk/rust-jsg-io-context

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Rust APIs had no access to the request they run on behalf of.

This adds the io-context crate (src/workerd/io/io-context):

  • IoContext::current(lock) -> jsg::Result<IoContext<'s>>: outside a request, such as in a Worker's global scope, fails with the same error C++ IoContext::current() throws ("Disallowed operation called within global scope…"). The message is now IoContext::NO_REQUEST_MESSAGE, which Rust reads through the shim, so there is one copy.
  • IoContext::try_current(lock) -> Option<IoContext<'s>>.
  • now() -> jsg::Result<SystemTime>: IoContext::now(), the request's (Spectre-coarsened) clock, to the nanosecond.
  • write_logfwdr(channel, message): IoContext::writeLogfwdr() for one serialized Cap'n Proto message. In a Durable Object, the write waits for the output gate, as AnalyticsEngine::writeDataPoint() does. C++ keeps doing the waitUntil and pending-event bookkeeping. A message that is empty, not a whole number of words, or followed by more bytes is rejected.

It lives beside //src/workerd/io, in a package of its own, because //src/workerd/io depends on jsg.

How a handle refers to its IoContext

An IoContext<'s> holds the process-unique id of the C++ IoContext (IoContext::getId()), not a pointer to it. Every call into C++ looks up IoContext::tryCurrent() and fails unless its id matches. Ids are never reused, so a handle can never reach another request's IoContext, or one that has been destroyed, whatever code holds it. The shim returns only the current id (IoContext::tryGetCurrentId()), never a reference, so the crate has no hand-written unsafe. The 's lifetime ties a handle to the jsg::Lock it came from, and the handle is neither Send nor Sync.

The shims call no C++ JSG, and //src/rust/jsg:cpp-jsg-allowlist-test now checks them too.

Builtin modules see no request

A builtin's exports must not depend on whichever request happens to import it first. But either module registry can run a Rust builtin's add_builtin closure during a request:

  • the legacy registry the first time a request resolves the module, for example by reading the process global;
  • the new registry when a request resolves the synthetic module directly (resolveInternalModule, require, getBuiltinModule). Only a built-in ES module importing it went through the eval callback's SuppressIoContextScope.

Every Rust module callback goes through callModuleCallback in rust/jsg/jsg.h, which now constructs a CallScope around the call. jsg can't depend on the IoContext, so CallScope is a template parameter of RustModuleRegistry and RustBuiltinModuleAdapter. It has no default, so every registration has to choose one. The Node.js registrations in api/node/node.h pass SuppressIoContextScope, so IoContext::current() fails inside an add_builtin closure.

Not in this PR

  • await_io, addTask/waitUntil, IoOwn, metrics, timers and the rest of IoContext: they have no Rust consumer yet.
  • write_logfwdr() copies the message twice: C++ copies the bytes into an aligned message, then into the outgoing one. Passing the segments of a Rust capnp::message::Builder would save one copy.

@piscisaureus piscisaureus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main points: catching shouldn't be public and safe; the lifetime TODO (I think it's sound today, and it can be made checked cheaply, details inline); and write_logfwdr skips the DO output gate.

One question I couldn't settle, outside the diff: the legacy module registry evaluates internal modules synchronously via resolveInternalImport (jsg/modules.h ~707-714) with no SuppressIoContextScope. That's reachable during a request through the process getter (api/global-scope.c++ ~1365) and DO wrapper modules (api/worker-rpc.c++ ~3309), so module evaluation can see a request's IoContext. I haven't checked whether any Rust builtin is evaluated on those paths. If one can be, suppress the IoContext around Rust builtin evaluation, or document it.

Comment thread src/rust/jsg/v8.rs Outdated
Comment thread src/rust/io-context/lib.rs Outdated
Comment thread src/rust/io-context/ffi.c++ Outdated
Comment thread src/rust/io-context/lib.rs Outdated
Comment thread src/workerd/io/io-context/lib-test.rs
Comment thread src/rust/io-context/lib.rs Outdated
Comment thread src/rust/io-context/ffi.c++ Outdated
Base automatically changed from biwanczuk/rust-jsg-coercion-exceptions to main October 25, 2026 18:24
Add the io-context crate, beside //src/workerd/io in a package of its
own because //src/workerd/io depends on jsg. It has
IoContext::current() and try_current(), now(), the request's clock,
and write_logfwdr().

A handle holds the IoContext's id (IoContext::getId()), not a pointer.
Each call looks up IoContext::tryCurrent() and fails unless the id
matches, so a handle can never reach another request's IoContext or a
destroyed one, and lib.rs needs no unsafe.

Outside a request, current() fails with the error C++
IoContext::current() throws; the Rust copy of its message and
kAsyncIoErrorMessage name each other. In a Durable Object,
write_logfwdr() waits for the output gate, as
AnalyticsEngine::writeDataPoint() does, and it rejects anything but
exactly one serialized message. The shims use no C++ JSG and are
checked by cpp-jsg-allowlist-test.

io-context-test runs the crate in a real worker through TestFixture,
calling Rust through a test-only bridge: inside and outside a request,
logfwdr delivery and validation, the output gate, and stale ids.
@bartlomieju
bartlomieju force-pushed the biwanczuk/rust-jsg-io-context branch from fa976f7 to 68dddfd Compare October 25, 2026 20:24
@bartlomieju

bartlomieju commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

On the module registry question from the review: today's Rust builtins (node-internal:dns, node-internal:url) only construct a resource in their add_builtin closure and never look up the IoContext, so no Rust code observes a request during evaluation. If a future builtin did call IoContext::current() there, the id check keeps it sound: the handle can't outlive the closure's lock, and it can't be used from another request. IoContext::current() now documents that add_builtin closures should not call it. I left suppressing the IoContext around builtin evaluation for later, since no Rust builtin needs it yet.

Take the jsg::Error by reference in to_io(), and return errors rather
than unwrapping when converting the time to nanoseconds.
Each copy now names the other, and the test that checks they match.
Either module registry can run a Rust builtin's add_builtin closure
during a request: the legacy registry the first time a request
resolves the module, for example by reading the process global, and
the new registry when a request resolves the synthetic module
directly. The closure then saw that request's IoContext.

The adapters in rust/jsg/jsg.h now construct a CallScope around each
call of a Rust module callback, and the Node.js registration passes
SuppressIoContextScope. jsg cannot depend on the IoContext, so the
scope is a template parameter with no default, which makes every
registration choose it.

io-context-test resolves a probe module during a request through
each registry and checks that its closure saw no IoContext.
…-context

# Conflicts:
#	src/rust/jsg/modules.rs
lib-test.rs was listed in the crate's sources, but lib.rs never
declared it as a module, so it was never compiled and
io-context_test passed without running any tests.
@bartlomieju
bartlomieju marked this pull request as ready for review October 28, 2026 11:27
@bartlomieju
bartlomieju requested review from a team as code owners October 28, 2026 11:27
@clanky-the-clanker

clanky-the-clanker Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

✅ Approved — No issues found. The shim now passes the id and the shared message constant, so the crate has less surface area and no hand-written unsafe code. Every concern from the earlier discussion is still addressed.

Not opened by the correctness review: 5 changed files it was meant to read
  • src/rust/jsg/BUILD.bazel
  • src/rust/jsg/jsg.h
  • src/rust/jsg/modules.rs
  • src/workerd/api/node/node.h
  • src/workerd/io/io-context/BUILD.bazel

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.


✅ All checks passed

🔒 Security: ✅ LGTM

  • Handles: a handle holds only the process-unique IoContext id, and every shim call checks that id against the current context (ffi.c++:22-27). A handle therefore cannot reach another request's context or one that has been destroyed.
  • Message checks: write_logfwdr rejects a message that is empty, not a whole number of words, or followed by more bytes, and it does this before writing. FlatArrayMessageReader applies Cap'n Proto's traversal limits.
  • Durable Object output gate: in a Durable Object, the write waits for the output gate and then runs inside context.run().

📖 AGENTS.md: ✅ LGTM

The src/workerd/io/AGENTS.md addition is accurate. The crate sits beside its C++ home, as the repository's layout rules ask.


📋 Code Quality: ✅ LGTM

  • Id shim: io_context_try_current_id() wraps the existing IoContext::tryGetCurrentId(). Two static_asserts and std::bit_cast back the conversion between IoContext::Id and uint64_t.
  • Stale handles: currentWithId rejects any id that does not belong to the current IoContext.
  • Error message: NO_REQUEST_MESSAGE is now one constant used by both C++ IoContext::current() and the Rust shim, so the two can no longer drift apart.

⚡ Performance: ✅ LGTM

No performance issues found. write_logfwdr() copies the message twice, as the description says, which is acceptable for now.


📝 Documentation: ✅ LGTM

No documentation issues found.


Reviewed commit: 2a91b052 — same changes as af4b59c5, so this is that commit's review (no new review was run; line numbers are as of af4b59c5)

@ask-bonk

ask-bonk Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Since last review: 0 resolved, 0 still open, 0 new.
LGTM!

Carried forward from the last review: docs, kj-style (no author changes in their files since then; earlier findings stand)

Not re-run: api-compat, tests, compat-flags, design-simplicity, jsg-gc, memory-safety, rust-first (no author changes in their files since the last review)


Reviewed commit: 2a91b052 · github run

@bartlomieju
bartlomieju requested a review from jasnell October 28, 2026 15:51

@guybedford guybedford left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The id-checked handle settles the soundness question cleanly, and the CallScope wrapping closes the builtin-evaluation gap for both registries. A few nits inline, none blocking.

Comment thread src/workerd/io/io-context/ffi.rs Outdated
Comment thread src/workerd/io/io-context/lib.rs Outdated
Comment thread src/workerd/io/io-context/ffi.c++
bartlomieju and others added 4 commits October 28, 2026 22:35
The shim now returns the current IoContext's id, from
IoContext::tryGetCurrentId(), instead of a reference that Rust read
the id through. That drops the opaque IoContext type and all
hand-written unsafe code from the crate.

The message of the error IoContext::current() throws outside a
request becomes IoContext::NO_REQUEST_MESSAGE, which Rust reads
through the shim instead of keeping a copy, so the test that the
copies match goes away.

Also builds the test bridge against main, which removed
jsg::Number.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants