Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions crates/execution/src/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const NODE_KEEP_STDIN_OPEN_ENV: &str = "AGENTOS_KEEP_STDIN_OPEN";
const NODE_GUEST_ENTRYPOINT_ENV: &str = "AGENTOS_GUEST_ENTRYPOINT";
const NODE_GUEST_ENTRYPOINT_MODULE_MODE_ENV: &str = "AGENTOS_GUEST_ENTRYPOINT_MODULE_MODE";
const NODE_RETAIN_CONTEXT_ENV: &str = "AGENTOS_RETAIN_LANGUAGE_CONTEXT";
const NODE_COMPLETE_ON_SETTLE_ENV: &str = "AGENTOS_COMPLETE_ON_SETTLE";
const NODE_INLINE_FILE_PATH_ENV: &str = "AGENTOS_INLINE_FILE_PATH";
const NODE_USE_BUNDLED_TYPESCRIPT_ENV: &str = "AGENTOS_USE_BUNDLED_TYPESCRIPT";
const NODE_TYPESCRIPT_COMPILER_PATH_ENV: &str = "AGENTOS_TYPESCRIPT_COMPILER_PATH";
Expand Down Expand Up @@ -280,6 +281,7 @@ const RESERVED_NODE_ENV_KEYS: &[&str] = &[
NODE_IMPORT_CACHE_PATH_ENV,
NODE_KEEP_STDIN_OPEN_ENV,
NODE_RETAIN_CONTEXT_ENV,
NODE_COMPLETE_ON_SETTLE_ENV,
NODE_USE_BUNDLED_TYPESCRIPT_ENV,
NODE_TYPESCRIPT_COMPILER_PATH_ENV,
NODE_ALLOWED_BUILTINS_ENV,
Expand Down Expand Up @@ -2952,12 +2954,18 @@ impl JavascriptExecutionEngine {
record_js_start_phase("js_start_install_module_reader", phase_start.elapsed());

let phase_start = Instant::now();
let retain_context = request
.env
.get(NODE_RETAIN_CONTEXT_ENV)
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"));
let env_flag = |key: &str| {
request
.env
.get(key)
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
};
let retain_context = env_flag(NODE_RETAIN_CONTEXT_ENV);
let complete_on_settle = env_flag(NODE_COMPLETE_ON_SETTLE_ENV);
let prepared_execute = PreparedJavascriptExecute {
mode: u8::from(use_module_mode) | if retain_context { 2 } else { 0 },
mode: u8::from(use_module_mode)
| if retain_context { 2 } else { 0 }
| if complete_on_settle { 4 } else { 0 },
file_path: execution_file_path,
bridge_code: V8RuntimeHost::bridge_code().to_owned(),
post_restore_script: request
Expand Down
40 changes: 23 additions & 17 deletions crates/native-sidecar/src/language_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const TTY_ENV: &str = "AGENTOS_EXEC_TTY";
const TTY_COLS_ENV: &str = "AGENTOS_EXEC_TTY_COLS";
const TTY_ROWS_ENV: &str = "AGENTOS_EXEC_TTY_ROWS";
const RETAIN_LANGUAGE_CONTEXT_ENV: &str = "AGENTOS_RETAIN_LANGUAGE_CONTEXT";
const COMPLETE_ON_SETTLE_ENV: &str = "AGENTOS_COMPLETE_ON_SETTLE";
const GUEST_ENTRYPOINT_MODULE_MODE_ENV: &str = "AGENTOS_GUEST_ENTRYPOINT_MODULE_MODE";
const INLINE_FILE_PATH_ENV: &str = "AGENTOS_INLINE_FILE_PATH";
const USE_BUNDLED_TYPESCRIPT_ENV: &str = "AGENTOS_USE_BUNDLED_TYPESCRIPT";
Expand Down Expand Up @@ -115,6 +116,17 @@ fn inline_inputs_prefix(inputs: Option<String>, python: bool) -> String {
}
}

/// Wrap an evaluation so its result promise is the entry evaluation the
/// runtime waits for: the completion value of a script, or a top-level
/// `await` in a module. The call completes when that promise settles.
fn evaluation_source(expression: &str, result_path: &str, module: bool) -> String {
format!(
"{{ {}Promise.resolve((async () => ({expression}))()).then((value) => {{ let result; try {{ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') throw new TypeError('undefined, functions, and symbols are not supported'); result = JSON.stringify({{ __agentosEvaluation: true, ok: true, value }}); }} catch (error) {{ result = JSON.stringify({{ __agentosEvaluation: true, ok: false, error: `AgentOS evaluation result must be JSON-serializable: ${{error instanceof Error ? error.message : String(error)}}` }}); }} require('node:fs').writeFileSync({}, result); }}); }}",
if module { "await " } else { "" },
serde_json::to_string(result_path).expect("semantic result path serialization cannot fail")
)
}

fn semantic_result_path() -> String {
static NEXT_RESULT_ID: AtomicU64 = AtomicU64::new(1);
let nonce = NEXT_RESULT_ID.fetch_add(1, Ordering::Relaxed);
Expand Down Expand Up @@ -547,14 +559,10 @@ fn lower_operation(payload: RequestPayload) -> Result<LoweredOperation, SidecarE
let module = payload.format == Some(JavaScriptModuleFormat::Module);
let result_path = semantic_result_path();
let mut source = inline_inputs_prefix(payload.inputs, false);
// The interval keeps the event loop alive until the evaluated promise
// settles, so a promise that never settles ends at `timeoutMs`
// instead of the guest exiting without a result.
source.push_str(&format!(
"{{ const keepAlive = setInterval(() => {{}}, 2147483647); Promise.resolve((async () => ({}))()).then((value) => {{ let result; try {{ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') throw new TypeError('undefined, functions, and symbols are not supported'); result = JSON.stringify({{ __agentosEvaluation: true, ok: true, value }}); }} catch (error) {{ result = JSON.stringify({{ __agentosEvaluation: true, ok: false, error: `AgentOS evaluation result must be JSON-serializable: ${{error instanceof Error ? error.message : String(error)}}` }}); }} require('node:fs').writeFileSync({}, result); }}).finally(() => clearInterval(keepAlive)); }}",
payload.expression,
serde_json::to_string(&result_path)
.expect("semantic result path serialization cannot fail")
source.push_str(&evaluation_source(
&payload.expression,
&result_path,
module,
));
let mut operation =
lowered_process(payload.process, "node", vec![String::from("-e"), source]);
Expand All @@ -571,6 +579,9 @@ fn lower_operation(payload: RequestPayload) -> Result<LoweredOperation, SidecarE
operation
.env
.insert(String::from(INLINE_FILE_PATH_ENV), file_path);
operation
.env
.insert(String::from(COMPLETE_ON_SETTLE_ENV), String::from("1"));
operation.value_kind = ExecutionValueKind::JavaScript;
operation.semantic_result_path = Some(result_path);
operation
Expand Down Expand Up @@ -601,15 +612,7 @@ fn lower_operation(payload: RequestPayload) -> Result<LoweredOperation, SidecarE
.file_path
.unwrap_or_else(|| String::from("agentos-evaluation.ts"));
let mut source = inline_inputs_prefix(payload.inputs, false);
// The interval keeps the event loop alive until the evaluated promise
// settles, so a promise that never settles ends at `timeoutMs`
// instead of the guest exiting without a result.
source.push_str(&format!(
"{{ const keepAlive = setInterval(() => {{}}, 2147483647); Promise.resolve((async () => ({}))()).then((value) => {{ let result; try {{ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') throw new TypeError('undefined, functions, and symbols are not supported'); result = JSON.stringify({{ __agentosEvaluation: true, ok: true, value }}); }} catch (error) {{ result = JSON.stringify({{ __agentosEvaluation: true, ok: false, error: `AgentOS evaluation result must be JSON-serializable: ${{error instanceof Error ? error.message : String(error)}}` }}); }} require('node:fs').writeFileSync({}, result); }}).finally(() => clearInterval(keepAlive)); }}",
payload.expression,
serde_json::to_string(&result_path)
.expect("semantic result path serialization cannot fail")
));
source.push_str(&evaluation_source(&payload.expression, &result_path, false));
let source = transform_retained_typescript_module(&source, &file_path)?;
let mut operation =
lowered_process(payload.process, "node", vec![String::from("-e"), source]);
Expand All @@ -619,6 +622,9 @@ fn lower_operation(payload: RequestPayload) -> Result<LoweredOperation, SidecarE
operation
.env
.insert(String::from(INLINE_FILE_PATH_ENV), file_path);
operation
.env
.insert(String::from(COMPLETE_ON_SETTLE_ENV), String::from("1"));
operation.value_kind = ExecutionValueKind::JavaScript;
operation.semantic_result_path = Some(result_path);
operation
Expand Down
85 changes: 85 additions & 0 deletions crates/native-sidecar/tests/language_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,91 @@ fn execution_timeout_is_enforced_by_the_sidecar() {
dispose_vm_and_close_session_wire(&mut sidecar, &connection_id, &session_id, &vm_id);
}

#[test]
fn context_call_completes_when_its_result_settles_despite_leftover_handles() {
let mut sidecar = new_sidecar("language-execution-leftover-handles");
let connection_id = authenticate_wire(&mut sidecar, "leftover-handles-connection");
let session_id = open_session_wire(&mut sidecar, 2, &connection_id);
let cwd = temp_dir("language-execution-leftover-handles-cwd");
let (vm_id, _) = create_vm_wire(
&mut sidecar,
3,
&connection_id,
&session_id,
wire::GuestRuntimeKind::JavaScript,
&cwd,
);

let mut request_id = 4;
let mut evaluate =
|sidecar: &mut agentos_native_sidecar::NativeSidecar<support::RecordingBridge>,
context_id: &str,
format: wire::JavaScriptModuleFormat,
expression: &str| {
let mut process = context_process_options(context_id);
process.timeout_ms = Some(5_000);
request_id += 1;
let started = sidecar
.dispatch_wire_blocking(wire_request(
request_id,
wire_vm(&connection_id, &session_id, &vm_id),
wire::RequestPayload::JavaScriptEvaluationRequest(
wire::JavaScriptEvaluationRequest {
process,
expression: expression.to_owned(),
format: Some(format),
file_path: None,
inputs: None,
},
),
))
.expect("start context evaluation");
let execution_id = accepted_execution_id(started);
wait_for_execution(sidecar, &connection_id, &session_id, &vm_id, &execution_id)
};

for (context_id, format) in [
("leftover-module", wire::JavaScriptModuleFormat::Module),
("leftover-commonjs", wire::JavaScriptModuleFormat::CommonJs),
] {
create_context(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
context_id,
);
let first = evaluate(
&mut sidecar,
context_id,
format.clone(),
"(setInterval(() => {}, 50), globalThis.answer = 41)",
);
assert_eq!(
first.outcome,
wire::ExecutionOutcome::Succeeded,
"{context_id}: a leftover interval must not fail the call: {:?}",
first.error
);
assert_eq!(first.evaluation_value.as_deref(), Some("41"));

let second = evaluate(&mut sidecar, context_id, format, "globalThis.answer + 1");
assert_eq!(
second.outcome,
wire::ExecutionOutcome::Succeeded,
"{context_id}: {:?}",
second.error
);
assert_eq!(
second.evaluation_value.as_deref(),
Some("42"),
"{context_id}: the context must keep state set by the first call"
);
}

dispose_vm_and_close_session_wire(&mut sidecar, &connection_id, &session_id, &vm_id);
}

#[test]
fn evaluation_rejects_non_json_values_with_a_structured_result() {
let mut sidecar = new_sidecar("language-execution-json-evaluation");
Expand Down
1 change: 1 addition & 0 deletions crates/v8-runtime/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- In `src/session.rs`, top-level `SessionMessage` handling must never silently drop late `BridgeResponse`, `StreamEvent`, or `TerminateExecution` frames after an execution finishes. If there is no active event loop to consume them, emit a structured `RuntimeEvent::Log` warning with an explicit `ERR_LATE_*` code so host-side diagnostics can see the loss.
- In `src/session.rs`, sync bridge waits need the same per-execution abort path for both CPU timeouts and explicit `TerminateExecution` requests. If a terminate signal only hits `isolate_handle.terminate_execution()` and does not also close the active bridge-wait receiver, a guest blocked inside a sync host call will hang until the host replies.
- In `src/session.rs`, `run_event_loop()` must consult `_getPendingTimerCount()` directly in its exit checks, not only pending promises or `_waitForActiveHandles()`. Some callers enter the loop without first registering the keepalive promise, and otherwise a lone ref'd `setInterval()` can let the session exit between ticks.
- In `src/session.rs`, `Execute` mode bit `2` (retained context) or bit `4` (semantic evaluation) means complete-on-settle: the loop exits when the entry evaluation settles (`EventLoopExit::EvaluationSettled`), then `_releaseLeftoverHandles()` cancels leftover timers and closes ref'd handles, then the loop runs until no async bridge call is in flight (`EventLoopExit::BridgeIdle`). Never apply `_waitForActiveHandles()` to these calls; the idle session loop does not run guest callbacks, so anything left would freeze or keep the call open until its timeout.
- Session-quota regressions now live in `src/embedded_runtime.rs`, not a removed daemon `main.rs`: keep one `SessionManager` per `EmbeddedV8Runtime`, share it across every runtime handle/connection to that instance, and cover quota behavior in `tests/embedded_runtime_session.rs`.
- In `tests/embedded_runtime_session.rs`, shared-quota assertions must saturate the runtime slots before registering any session that is supposed to stay queued. `SessionManager` acquires slots during `CreateSession`, so relying on creation order alone makes queued-session tests race with the sessions meant to hold the slots.
- Guest `process.memoryUsage()`, `process.cpuUsage()`, `process.resourceUsage()`, and live `process.versions.v8` should be resolved locally on the V8 session thread in `src/bridge.rs`, using `v8::HeapStatistics`, `getrusage(RUSAGE_THREAD)`, and the bundled library version APIs. Do not reintroduce stale JS literals or route these per-isolate values back through sidecar RPC.
Expand Down
3 changes: 2 additions & 1 deletion crates/v8-runtime/src/embedded_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,8 +593,9 @@ impl EmbeddedV8SessionHandle {
}
}

// Mode bits: 1 = module, 2 = retained context, 4 = complete on settle.
fn validate_execute_mode(mode: u8) -> io::Result<()> {
if mode > 3 {
if mode > 7 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown Execute mode: {mode}"),
Expand Down
23 changes: 23 additions & 0 deletions crates/v8-runtime/src/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,29 @@ pub fn finalize_pending_script_evaluation(
}
}

/// Cancel the timers and close the handles a settle-complete call left behind.
/// The bridge owns both, so a context without the bridge has nothing to release.
pub fn release_leftover_handles(scope: &mut v8::HandleScope) -> Result<(), ExecutionError> {
let tc = &mut v8::TryCatch::new(scope);
let context = tc.get_current_context();
let global = context.global(tc);
let Some(key) = v8::String::new(tc, "_releaseLeftoverHandles") else {
return Ok(());
};
let Some(release) = global
.get(tc, key.into())
.and_then(|value| v8::Local::<v8::Function>::try_from(value).ok())
else {
return Ok(());
};
release.call(tc, global.into(), &[]);
tc.perform_microtask_checkpoint();
match tc.exception() {
Some(exception) => Err(exception_to_result(tc, exception).1),
None => Ok(()),
}
}

fn serialize_module_exports(
scope: &mut v8::HandleScope,
module: v8::Local<v8::Module>,
Expand Down
4 changes: 2 additions & 2 deletions crates/v8-runtime/src/runtime_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ impl TryFrom<BinaryFrame> for RuntimeCommand {
high_resolution_time,
user_code,
} => {
if mode > 3 {
if mode > 7 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown Execute mode: {mode}"),
Expand Down Expand Up @@ -355,7 +355,7 @@ mod tests {
fn rejects_unknown_execute_mode() {
let err = RuntimeCommand::try_from(BinaryFrame::Execute {
session_id: "s".into(),
mode: 4,
mode: 8,
file_path: "/app/main.mjs".into(),
bridge_code: String::new(),
post_restore_script: String::new(),
Expand Down
Loading
Loading