Skip to content
45 changes: 40 additions & 5 deletions packages/compiler/src/frontend/lowering/surfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,39 @@ export function sideEffectFreeOptionValue(node: ts.Expression): boolean {
if (ts.isArrayLiteralExpression(e)) {
return e.elements.every((el) => !ts.isSpreadElement(el) && sideEffectFreeOptionValue(el));
}
// Arithmetic over NUMERIC LITERALS only (`maxBuffer: 64 * 1024 * 1024`).
// Deliberately not the general `sideEffectFreeOptionValue` recursion: an
// identifier operand is a side-effect-free READ, but arithmetic on it
// would call the value's valueOf/toString. Literal operands cannot.
if (ts.isBinaryExpression(e) && NUMERIC_FOLD_OPS.has(e.operatorToken.kind)) {
return numericLiteralOperand(e.left) && numericLiteralOperand(e.right);
}
return false;
}

/** The arithmetic operators a constant numeric option value may use. */
const NUMERIC_FOLD_OPS: ReadonlySet<ts.SyntaxKind> = new Set([
ts.SyntaxKind.PlusToken,
ts.SyntaxKind.MinusToken,
ts.SyntaxKind.AsteriskToken,
ts.SyntaxKind.SlashToken,
ts.SyntaxKind.PercentToken,
ts.SyntaxKind.AsteriskAsteriskToken,
]);

/** A numeric literal, a signed one, or arithmetic over those — the operand
* shape whose evaluation provably observes nothing. */
function numericLiteralOperand(node: ts.Expression): boolean {
let e = node;
while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isTypeAssertion(e)) e = e.expression;
if (ts.isNumericLiteral(e)) return true;
if (ts.isPrefixUnaryExpression(e) &&
(e.operator === ts.SyntaxKind.MinusToken || e.operator === ts.SyntaxKind.PlusToken)) {
return numericLiteralOperand(e.operand);
}
if (ts.isBinaryExpression(e) && NUMERIC_FOLD_OPS.has(e.operatorToken.kind)) {
return numericLiteralOperand(e.left) && numericLiteralOperand(e.right);
}
return false;
}

Expand Down Expand Up @@ -798,6 +831,13 @@ export const BUILTIN_MODULE_FNS: Record<string, Record<string, BuiltinModuleFn |
spawnSync: { fn: "cp.spawnSync", params: [STRING, arrayOf(STRING)], result: SPAWNRES_T },
spawn: { fn: "cp.spawn", params: [STRING, arrayOf(STRING)], result: CHILD_T },
fork: { fn: "cp.fork", params: [STRING, arrayOf(STRING)], result: CHILD_T },
// execFile(file, args?, options?, callback) — the callback form, whose
// real shape (the trailing error-first callback, and the
// encoding/maxBuffer option literal) lowerExecFileCall owns. Like
// spawn/fork above, the entry exists so this member reads as CLAIMED
// surface: without it the fence in lower-calls.ts refuses the call
// before lowerBuiltinModuleCall's dispatch can run.
execFile: { fn: "cp.execFile", params: [STRING, arrayOf(STRING)], result: CHILD_T },
// execFileSync(file, args?, options?) and execSync(command, options?)
// share the cp.execSync runtime entry (execSync sets the shell flag);
// both call completions are special-cased in lowerBuiltinModuleCall.
Expand Down Expand Up @@ -1173,11 +1213,6 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record<string, Record<string, string |
"a compiled program has no live builtin ESM namespace bindings to synchronize — " +
"nothing a compiled surface can mutate makes the call observable; remove it",
},
child_process: {
execFile:
"the callback form has no lowering — promisify it: " +
"const execFileAsync = promisify(execFile) (from node:util), or use execFileSync",
},
crypto: {
...Object.fromEntries(
[
Expand Down
8 changes: 4 additions & 4 deletions packages/compiler/surface-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -534,10 +534,10 @@
"id": "node-builtin.child_process.execFile",
"kind": "node-builtin",
"name": "child_process.execFile",
"status": "unsupported",
"backends": "unknown",
"code": "SC2020",
"note": "the callback form has no lowering — promisify it: const execFileAsync = promisify(execFile) (from node:util), or use execFileSync"
"status": "static",
"backends": [
"rust"
]
},
{
"id": "node-builtin.child_process.execFileSync",
Expand Down
9 changes: 6 additions & 3 deletions packages/runtime-rust/src/arrays.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ struct ArraySparse {
pub struct ArrayData<T: ArrayElement> {
elements: Vec<T>,
auxiliary: Option<Rc<ArrayAux<T>>>,
// `Box` keeps the common property-free array at its small size, matching
// the `sparse` field below; a bare `Vec` would widen every `ArrayData`.
#[allow(clippy::box_collection)]
properties: Option<Box<Vec<(JsString, T)>>>,
view: Option<Rc<dyn ArrayView<T>>>,
sparse: Option<Box<ArraySparse>>,
Expand Down Expand Up @@ -248,18 +251,18 @@ pub fn array_set_raw<T: ArrayElement>(array: &JsArray<T>, raw: JsArray<T>) {

pub fn array_property_get<T: ArrayElement>(array: &JsArray<T>, key: &JsString) -> Option<T> {
array.with(|data| data.properties.as_ref().and_then(|properties|
properties.iter().find(|(name, _)| name.as_ref() == key.as_ref()).map(|(_, value)| value.clone())))
properties.iter().find(|(name, _)| name == key).map(|(_, value)| value.clone())))
}

pub fn array_property_has<T: ArrayElement>(array: &JsArray<T>, key: &JsString) -> bool {
array.with(|data| data.properties.as_ref().is_some_and(|properties|
properties.iter().any(|(name, _)| name.as_ref() == key.as_ref())))
properties.iter().any(|(name, _)| name == key)))
}

pub fn array_property_set<T: ArrayElement>(array: &JsArray<T>, key: JsString, value: T) {
array.with_mut(|data| {
let properties = data.properties.get_or_insert_with(|| Box::new(Vec::new()));
if let Some((_, current)) = properties.iter_mut().find(|(name, _)| name.as_ref() == key.as_ref()) {
if let Some((_, current)) = properties.iter_mut().find(|(name, _)| name == &key) {
*current = value;
} else {
properties.push((key, value));
Expand Down
51 changes: 46 additions & 5 deletions packages/runtime-rust/src/child_ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,15 @@ enum ChildIpcEvent {
pub struct ChildIpcData {
sender: Option<std::sync::mpsc::Sender<ChildIpcCommand>>,
receiver: Option<std::sync::mpsc::Receiver<ChildIpcEvent>>,
/// The PUBLIC `connected` flag. Node clears it synchronously inside
/// disconnect(), so a read or a send that follows must already see the
/// channel as gone.
connected: bool,
/// Whether the channel is still DRAINING. It outlives `connected`: the
/// worker thread may still hand over framed messages after a local
/// disconnect, and the child exit event stays deferred until this
/// clears, so an EOF cannot drop a message that was already framed.
open: bool,
next_send_id: u64,
send_callbacks: HashMap<u64, ChildIpcSendCallback>,
message_listeners: Vec<ChildIpcMessageListener>,
Expand Down Expand Up @@ -65,6 +73,7 @@ impl ClearEdges for ChildIpcData {
}
self.receiver = None;
self.connected = false;
self.open = false;
self.send_callbacks.clear();
self.message_listeners.clear();
self.disconnect_listeners.clear();
Expand Down Expand Up @@ -211,6 +220,7 @@ fn child_ipc_new(endpoint: ChildIpcEndpoint) -> JsChildIpc {
sender: Some(command_sender),
receiver: Some(event_receiver),
connected: true,
open: true,
next_send_id: 1,
send_callbacks: HashMap::new(),
message_listeners: Vec::new(),
Expand All @@ -224,6 +234,12 @@ pub fn child_ipc_connected(channel: &JsChildIpc) -> bool {
channel.with(|channel| channel.connected)
}

/// Whether the channel still has to be drained — the liveness question, as
/// opposed to the public `connected` answer.
pub fn child_ipc_open(channel: &JsChildIpc) -> bool {
channel.with(|channel| channel.open)
}

fn child_ipc_send_inner(
channel: &JsChildIpc,
message: &JsString,
Expand Down Expand Up @@ -260,11 +276,32 @@ fn child_ipc_send_inner(

fn child_ipc_disconnect_inner(channel: &JsChildIpc) {
channel.with_mut(|channel| {
if channel.connected
&& let Some(sender) = &channel.sender
{
if !channel.connected {
return;
}
// Node closes the channel INSIDE disconnect(): `connected` reads false
// and a following send is refused on the spot. `open` stays set, so
// the loop still drains whatever the worker thread already framed and
// still defers the child exit event until it finishes.
channel.connected = false;
if let Some(sender) = &channel.sender {
let _ = sender.send(ChildIpcCommand::Disconnect);
}
// Node emits `disconnect` on the tick after a LOCAL disconnect(),
// not after a round trip to the channel thread. Taking the listeners
// here and queueing them keeps that order: anything the caller does
// after disconnect() — a refused send, whose callback also settles on
// a later tick — is queued behind this. The Disconnected event still
// arrives later and still fails the pending send callbacks, but finds
// no disconnect listener left to call, so they fire exactly once.
let listeners = std::mem::take(&mut channel.disconnect_listeners);
if !listeners.is_empty() {
process_next_tick(Box::new(move || {
for listener in listeners {
(listener.invoke)();
}
}));
}
});
}

Expand Down Expand Up @@ -319,10 +356,14 @@ fn child_ipc_dispatch(channel: &JsChildIpc, event: ChildIpcEvent) {
}
ChildIpcEvent::Disconnected => {
let (callbacks, listeners) = channel.with_mut(|channel| {
if !channel.connected {
// Guarded on `open`, not `connected`: a local disconnect() has
// already cleared `connected`, and the disconnect listeners must
// still fire exactly once when the channel actually ends.
if !channel.open {
return (Vec::new(), Vec::new());
}
channel.connected = false;
channel.open = false;
channel.sender = None;
let callbacks = std::mem::take(&mut channel.send_callbacks)
.into_values()
Expand Down Expand Up @@ -362,7 +403,7 @@ fn child_ipc_dispatch_one() -> bool {
}

fn child_ipc_pending() -> bool {
CHILD_IPC_CHANNELS.with(|channels| channels.borrow().iter().any(child_ipc_connected))
CHILD_IPC_CHANNELS.with(|channels| channels.borrow().iter().any(child_ipc_open))
}

fn child_ipc_finish() {
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-rust/src/child_process_async.rs
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,7 @@ enum ChildOutcome {
fn child_poll(child: &JsChild) -> Option<ChildOutcome> {
child.with_mut(|child| {
if let Some((code, signal)) = child.pending_exit.clone() {
if child.ipc.as_ref().is_some_and(child_ipc_connected) {
if child.ipc.as_ref().is_some_and(child_ipc_open) {
return None;
}
child.pending_exit = None;
Expand All @@ -688,7 +688,7 @@ fn child_poll(child: &JsChild) -> Option<ChildOutcome> {
child.process = None;
child.exit_code = status.code().map(f64::from);
let outcome = (child.exit_code, child_exit_signal(&status));
if child.ipc.as_ref().is_some_and(child_ipc_connected) {
if child.ipc.as_ref().is_some_and(child_ipc_open) {
child.pending_exit = Some(outcome);
None
} else {
Expand Down
15 changes: 13 additions & 2 deletions packages/runtime-rust/src/child_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,19 @@ fn child_writer_new(mut stdin: std::process::ChildStdin) -> JsChildWriter {
writer
}

/// The stdin handle of a child whose SPAWN failed. Node hands back a live
/// ChildProcess with a writable stdin and reports the failure on a later
/// turn, so the husk starts WRITABLE: a read before the error lands must
/// answer `true`, exactly like Node's. `children_dispatch_one` destroys it
/// just before it delivers the error, so a read from inside the `error`
/// listener answers `false`.
fn child_writer_husk() -> JsChildWriter {
Gc::new(ChildWriterData {
sender: None,
receiver: None,
queued: 0,
needs_drain: false,
writable: false,
writable: true,
finished: false,
drain_listeners: Vec::new(),
finish_listeners: Vec::new(),
Expand Down Expand Up @@ -341,7 +347,12 @@ fn child_writers_dispatch_one() -> bool {
writer.needs_drain = false;
writer.finished = true;
writer.drain_listeners.clear();
writer.error_listeners.clear();
// The error listeners SURVIVE finish. A `write` performed
// from inside the finish listener is Node's
// ERR_STREAM_WRITE_AFTER_END, reported on the stream's
// error event — clearing them here left
// `child_writer_fail` with nobody to call, so it threw the
// error uncaught and killed the program mid-output.
std::mem::take(&mut writer.finish_listeners)
});
child_writer_remove(&writer);
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-rust/src/effect_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ fn set_named(entries: &mut Vec<(JsString, JsString)>, name: &JsString, value: &J
entries.retain(|(current, _)| if ascii_case_insensitive {
!current.as_ref().eq_ignore_ascii_case(name.as_ref())
} else {
current.as_ref() != name.as_ref()
current != name
});
entries.push((name.clone(), value.clone()));
}
Expand Down
13 changes: 6 additions & 7 deletions packages/runtime-rust/src/json_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,18 @@ fn js_copy_with<T: ParseArgsValue>(object: &T, replace: Option<(&str, T)>, skip:
continue;
}
match &replace {
Some((target, new_value)) if target.as_ref() == key.as_ref() => {
Some((target, new_value)) if *target == key => {
out.parse_args_object_set(key, new_value.clone());
replaced = true;
}
_ => out.parse_args_object_set(key, value),
}
}
if !replaced {
if let Some((key, value)) = replace {
if !skip.contains(&{ let text: &str = key.as_ref(); text }) {
out.parse_args_object_set(key, value);
}
}
if !replaced
&& let Some((key, value)) = replace
&& !skip.contains(&{ let text: &str = key.as_ref(); text })
{
out.parse_args_object_set(key, value);
}
out
}
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-rust/src/regex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ pub fn regex_new<S: JsStringSource + ?Sized>(pattern: &S, flags: &str) -> JsRege
let flags = validate_regex_flags(flags);
let pattern = pattern.to_js_string();
let source = if pattern.is_empty() { string("(?:)") } else { pattern };
let named_groups = regex_named_capture_groups(&source.to_utf8_lossy());
let named_groups = regex_named_capture_groups(source.to_utf8_lossy());
let parsed_flags = regress::Flags::from(flags.as_str());
let unicode = flags.contains('u') || flags.contains('v');
let compiled = if unicode {
Expand Down
22 changes: 11 additions & 11 deletions packages/runtime-rust/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ fn schema_to_json<T: ParseArgsValue>(node: &SchemaNode) -> T {
let required: T = T::parse_args_array_value();
for field in fields {
let (field_schema, optional) = match &*field.node {
SchemaNode::OptionalKey(inner) => (schema_to_json(&**inner), true),
_ => (schema_to_json(&*field.node), false),
SchemaNode::OptionalKey(inner) => (schema_to_json(inner), true),
_ => (schema_to_json(&field.node), false),
};
properties.parse_args_object_set(field.name.clone(), field_schema);
if !optional {
Expand All @@ -297,12 +297,12 @@ fn schema_to_json<T: ParseArgsValue>(node: &SchemaNode) -> T {
}
SchemaNode::Array(item) => {
let out = schema_json_typed("array");
schema_json_set(&out, "items", schema_to_json(&**item));
schema_json_set(&out, "items", schema_to_json(item));
out
}
SchemaNode::Record(_, value) => {
let out = schema_json_typed("object");
let additional: T = schema_to_json(&**value);
let additional: T = schema_to_json(value);
if additional.parse_args_object_entries().is_some_and(|entries| !entries.is_empty()) {
schema_json_set(&out, "additionalProperties", additional);
}
Expand All @@ -313,18 +313,18 @@ fn schema_to_json<T: ParseArgsValue>(node: &SchemaNode) -> T {
if members.is_empty() {
schema_json_set(&out, "not", schema_json_object());
} else {
schema_json_set(&out, "anyOf", schema_json_array(members.iter().map(|member| schema_to_json(&**member))));
schema_json_set(&out, "anyOf", schema_json_array(members.iter().map(|member| schema_to_json(member))));
}
out
}
SchemaNode::NullOr(inner) | SchemaNode::UndefinedOr(inner) => {
let out = schema_json_object();
schema_json_set(&out, "anyOf", schema_json_array([schema_to_json(&**inner), schema_json_typed("null")]));
schema_json_set(&out, "anyOf", schema_json_array([schema_to_json(inner), schema_json_typed("null")]));
out
}
SchemaNode::OptionalKey(inner) | SchemaNode::Named(_, inner) => schema_to_json(&**inner),
SchemaNode::OptionalKey(inner) | SchemaNode::Named(_, inner) => schema_to_json(inner),
SchemaNode::Check(inner, filter) => {
let out = schema_to_json(&**inner);
let out = schema_to_json(inner);
schema_json_merge(&out, &schema_json_constraint(filter));
out
}
Expand All @@ -336,20 +336,20 @@ fn schema_to_json<T: ParseArgsValue>(node: &SchemaNode) -> T {
if elements.is_empty() {
schema_json_set(&out, "items", T::parse_args_bool_value(false));
} else {
schema_json_set(&out, "prefixItems", schema_json_array(elements.iter().map(|element| schema_to_json(&**element))));
schema_json_set(&out, "prefixItems", schema_json_array(elements.iter().map(|element| schema_to_json(element))));
schema_json_set(&out, "minItems", T::parse_args_number_value(elements.len() as f64));
schema_json_set(&out, "maxItems", T::parse_args_number_value(elements.len() as f64));
}
out
}
SchemaNode::DecodeTo(source, _, _) => schema_to_json(&**source),
SchemaNode::DecodeTo(source, _, _) => schema_to_json(source),
}
}

pub fn schema_to_json_document<T: ParseArgsValue>(schema: &JsEffect) -> T {
let document = schema_json_object();
schema_json_set(&document, "dialect", schema_json_string("draft-2020-12"));
schema_json_set(&document, "schema", schema_to_json(&*schema_node_of(schema)));
schema_json_set(&document, "schema", schema_to_json(&schema_node_of(schema)));
schema_json_set(&document, "definitions", schema_json_object());
document
}
Expand Down
1 change: 1 addition & 0 deletions tests/corpus/2911-execfile-callback.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @rust-only
// child_process.execFile's asynchronous callback slice: the call returns a
// live ChildProcess immediately, captures utf8 stdout/stderr, fires after
// the current turn, and reports non-zero/spawn failures through Error.
Expand Down
1 change: 1 addition & 0 deletions tests/corpus/2912-execfile-callback-options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @rust-only
// The inline utf8/maxBuffer callback shape preserves concurrent execution,
// streams and error-first result on both successful and failed children.
import { execFile } from "node:child_process";
Expand Down
Loading
Loading