Skip to content

Code mode on v8#15276

Merged
cconger merged 14 commits intomainfrom
cconger/code-mode-to-v8-on-v8-bazel-use
Mar 21, 2026
Merged

Code mode on v8#15276
cconger merged 14 commits intomainfrom
cconger/code-mode-to-v8-on-v8-bazel-use

Conversation

@cconger
Copy link
Contributor

@cconger cconger commented Mar 20, 2026

Moves Code Mode to a new crate with no dependencies on codex. This create encodes the code mode semantics that we want for lifetime, mounting, tool calling.

The model-facing surface is mostly unchanged. exec still runs raw JavaScript, wait still resumes or terminates a cell_id, nested tools are still available through tools.*, and helpers like text, image, store, load, notify, yield_control, and exit still exist.

The major change is underneath that surface:

  • Old code mode was an external Node runtime.
  • New code mode is an in-process V8 runtime embedded directly in Rust.
  • Old code mode managed cells inside a long-lived Node runner process.
  • New code mode manages cells in Rust, with one V8 runtime thread per active exec.
  • Old code mode used JSON protocol messages over child stdin/stdout plus Node worker-thread messages.
  • New code mode uses Rust channels and direct V8 callbacks/events.

This PR also fixes the two migration regressions that fell out of that substrate change:

  • wait { terminate: true } now waits for the V8 runtime to actually stop before reporting termination.
  • synchronous top-level exit() now succeeds again instead of surfacing as a script error.

  • core/src/tools/code_mode/* is now mostly an adapter layer for the public exec / wait tools.
  • code-mode/src/service.rs owns cell sessions and async control flow in Rust.
  • code-mode/src/runtime/*.rs owns the embedded V8 isolate and JavaScript execution.
  • each exec spawns a dedicated runtime thread plus a Rust session-control task.
  • helper globals are installed directly into the V8 context instead of being injected through a source prelude.
  • helper modules like tools.js and @openai/code_mode are synthesized through V8 module resolution callbacks in Rust.

Also added a benchmark for showing the speed of init and use of a code mode env:

$ cargo bench -p codex-code-mode --bench exec_overhead -- --samples 30 --warm-iterations 25 --tool-counts 0,32,128
Finished [`bench` profile [optimized]](https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles) target(s) in 0.18s
     Running benches/exec_overhead.rs (target/release/deps/exec_overhead-008c440d800545ae)
exec_overhead: samples=30, warm_iterations=25, tool_counts=[0, 32, 128]
scenario       tools samples    warmups      iters      mean/exec       p95/exec       rssΔ p50       rssΔ max
cold_exec          0      30          0          1         1.13ms         1.20ms        8.05MiB        8.06MiB
warm_exec          0      30          1         25       473.43us       512.49us      912.00KiB        1.33MiB
cold_exec         32      30          0          1         1.03ms         1.15ms        8.08MiB        8.11MiB
warm_exec         32      30          1         25       509.73us       545.76us      960.00KiB        1.30MiB
cold_exec        128      30          0          1         1.14ms         1.19ms        8.30MiB        8.34MiB
warm_exec        128      30          1         25       575.08us       591.03us      736.00KiB      864.00KiB
memory uses a fresh-process max RSS delta for each scenario

@cconger cconger force-pushed the cconger/code-mode-to-v8-on-v8-bazel-use branch 2 times, most recently from a0b714e to e4ea088 Compare March 20, 2026 21:21
@cconger cconger mentioned this pull request Mar 20, 2026
@cconger cconger requested a review from pakrym-oai March 20, 2026 21:32
@cconger cconger force-pushed the cconger/code-mode-to-v8-on-v8-bazel-use branch from e4ea088 to 5cbeb96 Compare March 20, 2026 21:55
&& exception.to_rust_string_lossy(scope) == EXIT_SENTINEL
}

pub(super) fn resolve_tool_response(
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: this method almost doesn't fit in this file.

}

#[derive(Debug, PartialEq)]
pub enum RuntimeResponse {
Copy link
Collaborator

Choose a reason for hiding this comment

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

ToolResponse maybe?

call_id: String,
text: String,
},
Result {
Copy link
Collaborator

Choose a reason for hiding this comment

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

There is some overlap between this and RuntimeResponse. Both have stored valis and notify.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

RuntimeEvent is js => code_mode
RuntimeResponse is code_mode => Core

I think there's a lot of overlap but I think we likely shouldn't coalesce these types.

Comment on lines +233 to +236
let _ = event_tx.send(RuntimeEvent::Result {
stored_values,
error_text: Some(error_text),
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

can we extract the send error boilderplate?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe have normal rust error handling in inner method and outer method that has a single place to report errors.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like this idea, I'd like to think on it some more and maybe follow up.

}
};

match module_loader::completion_state(scope, pending_promise.as_ref()) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

do we need the pre-check or can we let the loop run and hit completion case naturally?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, I think we need this in the cases where the script finished right away, since the loop is blocked on the command_rx.recv().


let resolver = v8::Global::new(scope, resolver);
let Some(state) = scope.get_slot_mut::<RuntimeState>() else {
throw_type_error(scope, "runtime state unavailable");
Copy link
Collaborator

Choose a reason for hiding this comment

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

same nit about native rust error handling that turns into throw_type_error in one location. Maybe .map_err(throw_type_error) in the caller

} else {
args.get(0)
};
let text = match serialize_output_text(scope, value) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Good question, do we serialize the argument here? Or do we expect text? Might make sense to align with text method as you did here.

}

pub async fn replace_stored_values(&self, values: HashMap<String, JsonValue>) {
*self.inner.stored_values.lock().await = values;
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit for later. this should probably be merge.


impl Drop for CodeModeTurnWorker {
fn drop(&mut self) {
if let Some(shutdown_tx) = self.shutdown_tx.take() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

do we want to join the task? or abort the task?

Copy link
Collaborator

Choose a reason for hiding this comment

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

bit worrysome to let it hang

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah agreed. I think I probably need to have stricter lifecycle management for the Worker and channel. I need to think on it some.

tokio::select! {
maybe_event = async {
if runtime_closed {
std::future::pending::<Option<RuntimeEvent>>().await
Copy link
Collaborator

Choose a reason for hiding this comment

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

if runtime is closed what are we waiting for?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If the runtime queue gets closed its possible that there was already a Result to be processed in our event_rx or if one was pending. We keep listening until the v8 thread resolves which it should quickly after we hit a terminate_execution on the handle.

@cconger cconger merged commit e4eedd6 into main Mar 21, 2026
42 of 43 checks passed
@cconger cconger deleted the cconger/code-mode-to-v8-on-v8-bazel-use branch March 21, 2026 06:37
@github-actions github-actions bot locked and limited conversation to collaborators Mar 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants