Repository: oven-sh/bun
Pull request #18725: Remove usage of JSC.Strong from Subprocess
Author: login: Jarred-Sumner
Created: 2025-04-02T22:26:50Z | Merged: 2025-04-03T17:51:20Z

### What does this PR do?

This is a speculative fix to reduce memory usage of Bun.spawn

### How did you verify your code works?

CI


Diff:
diff --git a/src/bun.js/api/BunObject.classes.ts b/src/bun.js/api/BunObject.classes.ts
index 6d3e289f82d..455c750f5a6 100644
--- a/src/bun.js/api/BunObject.classes.ts
+++ b/src/bun.js/api/BunObject.classes.ts
@@ -117,10 +117,12 @@ export default [
       },
       exited: {
         getter: "getExited",
+        this: true,
       },
       stdio: {
         getter: "getStdio",
       },
     },
+    values: ["exitedPromise", "onExitCallback", "onDisconnectCallback", "ipcCallback"],
   }),
 ];
diff --git a/src/bun.js/api/bun/subprocess.zig b/src/bun.js/api/bun/subprocess.zig
index d6edf19a1f9..a084a1c5bd8 100644
--- a/src/bun.js/api/bun/subprocess.zig
+++ b/src/bun.js/api/bun/subprocess.zig
@@ -11,10 +11,6 @@ stderr: Readable,
 stdio_pipes: if (Environment.isWindows) std.ArrayListUnmanaged(StdioResult) else std.ArrayListUnmanaged(bun.FileDescriptor) = .{},
 pid_rusage: ?Rusage = null,
 
-exit_promise: JSC.Strong = .empty,
-on_exit_callback: JSC.Strong = .empty,
-on_disconnect_callback: JSC.Strong = .empty,
-
 globalThis: *JSC.JSGlobalObject,
 observable_getters: std.enums.EnumSet(enum {
     stdin,
@@ -28,7 +24,6 @@ this_jsvalue: JSC.JSValue = .zero,
 
 /// `null` indicates all of the IPC data is uninitialized.
 ipc_data: ?IPC.IPCData,
-ipc_callback: JSC.Strong = .empty,
 flags: Flags = .{},
 
 weak_file_sink_stdin_ptr: ?*JSC.WebCore.FileSink = null,
@@ -592,13 +587,17 @@ pub fn getStdout(
 pub fn asyncDispose(
     this: *Subprocess,
     global: *JSGlobalObject,
-    _: *JSC.CallFrame,
+    callframe: *JSC.CallFrame,
 ) bun.JSError!JSValue {
     if (this.process.hasExited()) {
         // rely on GC to clean everything up in this case
         return .undefined;
     }
 
+    const this_jsvalue = callframe.this();
+
+    defer this_jsvalue.ensureStillAlive();
+
     // unref streams so that this disposed process will not prevent
     // the process from exiting causing a hang
     this.stdin.unref();
@@ -613,7 +612,7 @@ pub fn asyncDispose(
         },
     }
 
-    return this.getExited(global);
+    return this.getExited(this_jsvalue, global);
 }
 
 fn setEventLoopTimerRefd(this: *Subprocess, refd: bool) void {
@@ -1531,6 +1530,30 @@ pub fn memoryCost(this: *const Subprocess) usize {
         this.stderr.memoryCost();
 }
 
+fn consumeExitedPromise(this_jsvalue: JSValue, globalThis: *JSC.JSGlobalObject) ?JSValue {
+    if (JSC.Codegen.JSSubprocess.exitedPromiseGetCached(this_jsvalue)) |promise| {
+        JSC.Codegen.JSSubprocess.exitedPromiseSetCached(this_jsvalue, globalThis, .zero);
+        return promise;
+    }
+    return null;
+}
+
+fn consumeOnExitCallback(this_jsvalue: JSValue, globalThis: *JSC.JSGlobalObject) ?JSValue {
+    if (JSC.Codegen.JSSubprocess.onExitCallbackGetCached(this_jsvalue)) |callback| {
+        JSC.Codegen.JSSubprocess.onExitCallbackSetCached(this_jsvalue, globalThis, .zero);
+        return callback;
+    }
+    return null;
+}
+
+fn consumeOnDisconnectCallback(this_jsvalue: JSValue, globalThis: *JSC.JSGlobalObject) ?JSValue {
+    if (JSC.Codegen.JSSubprocess.onDisconnectCallbackGetCached(this_jsvalue)) |callback| {
+        JSC.Codegen.JSSubprocess.onDisconnectCallbackSetCached(this_jsvalue, globalThis, .zero);
+        return callback;
+    }
+    return null;
+}
+
 pub fn onProcessExit(this: *Subprocess, process: *Process, status: bun.spawn.Status, rusage: *const Rusage) void {
     log("onProcessExit()", .{});
     const this_jsvalue = this.this_jsvalue;
@@ -1592,55 +1615,57 @@ pub fn onProcessExit(this: *Subprocess, process: *Process, status: bun.spawn.Sta
     const loop = jsc_vm.eventLoop();
 
     if (!is_sync) {
-        if (this.exit_promise.trySwap()) |promise| {
-            loop.enter();
-            defer loop.exit();
+        if (this_jsvalue != .zero) {
+            if (consumeExitedPromise(this_jsvalue, globalThis)) |promise| {
+                loop.enter();
+                defer loop.exit();
+
+                if (!did_update_has_pending_activity) {
+                    this.updateHasPendingActivity();
+                    did_update_has_pending_activity = true;
+                }
 
-            if (!did_update_has_pending_activity) {
-                this.updateHasPendingActivity();
-                did_update_has_pending_activity = true;
+                switch (status) {
+                    .exited => |exited| promise.asAnyPromise().?.resolve(globalThis, JSValue.jsNumber(exited.code)),
+                    .err => |err| promise.asAnyPromise().?.reject(globalThis, err.toJSC(globalThis)),
+                    .signaled => promise.asAnyPromise().?.resolve(globalThis, JSValue.jsNumber(128 +% @intFromEnum(status.signaled))),
+                    else => {
+                        // crash in debug mode
+                        if (comptime Environment.allow_assert)
+                            unreachable;
+                    },
+                }
             }
 
-            switch (status) {
-                .exited => |exited| promise.asAnyPromise().?.resolve(globalThis, JSValue.jsNumber(exited.code)),
-                .err => |err| promise.asAnyPromise().?.reject(globalThis, err.toJSC(globalThis)),
-                .signaled => promise.asAnyPromise().?.resolve(globalThis, JSValue.jsNumber(128 +% @intFromEnum(status.signaled))),
-                else => {
-                    // crash in debug mode
-                    if (comptime Environment.allow_assert)
-                        unreachable;
-                },
-            }
-        }
+            if (consumeOnExitCallback(this_jsvalue, globalThis)) |callback| {
+                const waitpid_value: JSValue =
+                    if (status == .err)
+                        status.err.toJSC(globalThis)
+                    else
+                        .undefined;
 
-        if (this.on_exit_callback.trySwap()) |callback| {
-            const waitpid_value: JSValue =
-                if (status == .err)
-                    status.err.toJSC(globalThis)
-                else
-                    .undefined;
+                const this_value = if (this_jsvalue.isEmptyOrUndefinedOrNull()) .undefined else this_jsvalue;
+                this_value.ensureStillAlive();
 
-            const this_value = if (this_jsvalue.isEmptyOrUndefinedOrNull()) .undefined else this_jsvalue;
-            this_value.ensureStillAlive();
+                const args = [_]JSValue{
+                    this_value,
+                    this.getExitCode(globalThis),
+                    this.getSignalCode(globalThis),
+                    waitpid_value,
+                };
 
-            const args = [_]JSValue{
-                this_value,
-                this.getExitCode(globalThis),
-                this.getSignalCode(globalThis),
-                waitpid_value,
-            };
+                if (!did_update_has_pending_activity) {
+                    this.updateHasPendingActivity();
+                    did_update_has_pending_activity = true;
+                }
 
-            if (!did_update_has_pending_activity) {
-                this.updateHasPendingActivity();
-                did_update_has_pending_activity = true;
+                loop.runCallback(
+                    callback,
+                    globalThis,
+                    this_value,
+                    &args,
+                );
             }
-
-            loop.runCallback(
-                callback,
-                globalThis,
-                this_value,
-                &args,
-            );
         }
     }
 }
@@ -1693,10 +1718,6 @@ pub fn finalizeStreams(this: *Subprocess) void {
         }
         this.stdio_pipes.clearAndFree(bun.default_allocator);
     }
-
-    this.exit_promise.deinit();
-    this.on_exit_callback.deinit();
-    this.on_disconnect_callback.deinit();
 }
 
 pub fn deinit(this: *Subprocess) void {
@@ -1739,8 +1760,13 @@ pub fn finalize(this: *Subprocess) callconv(.C) void {
 
 pub fn getExited(
     this: *Subprocess,
+    this_value: JSValue,
     globalThis: *JSGlobalObject,
 ) JSValue {
+    if (JSC.Codegen.JSSubprocess.exitedPromiseGetCached(this_value)) |promise| {
+        return promise;
+    }
+
     switch (this.process.status) {
         .exited => |exit| {
             return JSC.JSPromise.resolvedPromiseValue(globalThis, JSValue.jsNumber(exit.code));
@@ -1752,11 +1778,9 @@ pub fn getExited(
             return JSC.JSPromise.rejectedPromiseValue(globalThis, err.toJSC(globalThis));
         },
         else => {
-            if (!this.exit_promise.has()) {
-                this.exit_promise.set(globalThis, JSC.JSPromise.create(globalThis).asValue(globalThis));
-            }
-
-            return this.exit_promise.get().?;
+            const promise = JSC.JSPromise.create(globalThis).asValue(globalThis);
+            JSC.Codegen.JSSubprocess.exitedPromiseSetCached(this_value, globalThis, promise);
+            return promise;
         },
     }
 }
@@ -2274,10 +2298,7 @@ pub fn spawnMaybeSync(
         .stdout = .{ .ignore = {} },
         .stderr = .{ .ignore = {} },
         .stdio_pipes = .{},
-        .on_exit_callback = .empty,
-        .on_disconnect_callback = .empty,
         .ipc_data = null,
-        .ipc_callback = .empty,
         .flags = .{
             .is_sync = is_sync,
         },
@@ -2325,15 +2346,13 @@ pub fn spawnMaybeSync(
         // 2. Process.
         .ref_count = 2,
         .stdio_pipes = spawned.extra_pipes.moveToUnmanaged(),
-        .on_exit_callback = JSC.Strong.create(on_exit_callback, globalThis),
-        .on_disconnect_callback = JSC.Strong.create(on_disconnect_callback, globalThis),
         .ipc_data = if (!is_sync and comptime Environment.isWindows)
             if (maybe_ipc_mode) |ipc_mode| .{
                 .mode = ipc_mode,
             } else null
         else
             null,
-        .ipc_callback = JSC.Strong.create(ipc_callback, globalThis),
+
         .flags = .{
             .is_sync = is_sync,
         },
@@ -2394,6 +2413,18 @@ pub fn spawnMaybeSync(
     var send_exit_notification = false;
 
     if (comptime !is_sync) {
+        bun.debugAssert(out != .zero);
+
+        if (on_exit_callback.isCell()) {
+            JSC.Codegen.JSSubprocess.onExitCallbackSetCached(out, globalThis, on_exit_callback);
+        }
+        if (on_disconnect_callback.isCell()) {
+            JSC.Codegen.JSSubprocess.onDisconnectCallbackSetCached(out, globalThis, on_disconnect_callback);
+        }
+        if (ipc_callback.isCell()) {
+            JSC.Codegen.JSSubprocess.ipcCallbackSetCached(out, globalThis, ipc_callback);
+        }
+
         switch (subprocess.process.watch()) {
             .result => {},
             .err => {
@@ -2561,13 +2592,18 @@ pub fn handleIPCMessage(
         },
         .data => |data| {
             IPC.log("Received IPC message from child", .{});
-            if (this.ipc_callback.get()) |cb| {
-                this.globalThis.bunVM().eventLoop().runCallback(
-                    cb,
-                    this.globalThis,
-                    this.this_jsvalue,
-                    &[_]JSValue{ data, this.this_jsvalue },
-                );
+            const this_jsvalue = this.this_jsvalue;
+            defer this_jsvalue.ensureStillAlive();
+            if (this_jsvalue != .zero) {
+                if (JSC.Codegen.JSSubprocess.ipcCallbackGetCached(this_jsvalue)) |cb| {
+                    const globalThis = this.globalThis;
+                    globalThis.bunVM().eventLoop().runCallback(
+                        cb,
+                        globalThis,
+                        this_jsvalue,
+                        &[_]JSValue{ data, this_jsvalue },
+                    );
+                }
             }
         },
         .internal => |data| {
@@ -2579,7 +2615,11 @@ pub fn handleIPCMessage(
 
 pub fn handleIPCClose(this: *Subprocess) void {
     IPClog("Subprocess#handleIPCClose", .{});
+    const this_jsvalue = this.this_jsvalue;
+    defer this_jsvalue.ensureStillAlive();
+    const globalThis = this.globalThis;
     this.updateHasPendingActivity();
+
     defer this.deref();
     var ok = false;
     if (this.ipc()) |ipc_data| {
@@ -2588,10 +2628,14 @@ pub fn handleIPCClose(this: *Subprocess) void {
     }
     this.ipc_data = null;
 
-    const this_jsvalue = this.this_jsvalue;
-    this_jsvalue.ensureStillAlive();
-    if (this.on_disconnect_callback.trySwap()) |callback| {
-        this.globalThis.bunVM().eventLoop().runCallback(callback, this.globalThis, this_jsvalue, &.{JSValue.jsBoolean(ok)});
+    if (this_jsvalue != .zero) {
+        // Avoid keeping the callback alive longer than necessary
+        JSC.Codegen.JSSubprocess.ipcCallbackSetCached(this_jsvalue, globalThis, .zero);
+
+        // Call the onDisconnectCallback if it exists and prevent it from being kept alive longer than necessary
+        if (consumeOnDisconnectCallback(this_jsvalue, globalThis)) |callback| {
+            globalThis.bunVM().eventLoop().runCallback(callback, globalThis, this_jsvalue, &.{JSValue.jsBoolean(ok)});
+        }
     }
 }
 


Reviews:
user: login: 190n
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T01:09:19Z
user: login: Jarred-Sumner
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T01:10:13Z
user: login: 190n
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T17:43:32Z

Review comments:
diff_hunk: @@ -592,13 +587,18 @@ pub fn getStdout(
 pub fn asyncDispose(
     this: *Subprocess,
     global: *JSGlobalObject,
-    _: *JSC.CallFrame,
+    callframe: *JSC.CallFrame,
 ) bun.JSError!JSValue {
     if (this.process.hasExited()) {
         // rely on GC to clean everything up in this case
         return .undefined;
     }
 
+    const this_jsvalue = callframe.this();
+
+    this_jsvalue.ensureStillAlive();
+    defer this_jsvalue.ensureStillAlive();
path: src/bun.js/api/bun/subprocess.zig
user: login: 190n
body: The non-deferred one is redundant. The deferred one might be too but I'm not quite confident enough in how CallFrame works to tell.
created_at: 2025-04-03T00:34:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1531,6 +1531,22 @@ pub fn memoryCost(this: *const Subprocess) usize {
         this.stderr.memoryCost();
 }
 
+pub fn consumeExitedPromise(this_jsvalue: JSValue, globalThis: *JSC.JSGlobalObject) ?JSValue {
+    if (JSC.Codegen.JSSubprocess.exitedPromiseGetCached(this_jsvalue)) |promise| {
+        JSC.Codegen.JSSubprocess.exitedPromiseSetCached(this_jsvalue, globalThis, .zero);
+        return promise;
+    }
+    return null;
+}
+
+pub fn consumeOnExitCallback(this_jsvalue: JSValue, globalThis: *JSC.JSGlobalObject) ?JSValue {
+    if (JSC.Codegen.JSSubprocess.onExitCallbackGetCached(this_jsvalue)) |callback| {
+        JSC.Codegen.JSSubprocess.onExitCallbackSetCached(this_jsvalue, globalThis, .zero);
+        return callback;
+    }
+    return null;
+}
+
path: src/bun.js/api/bun/subprocess.zig
user: login: 190n
body: these don't need to be `pub`
created_at: 2025-04-03T00:46:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2579,7 +2608,12 @@ pub fn handleIPCMessage(
 
 pub fn handleIPCClose(this: *Subprocess) void {
     IPClog("Subprocess#handleIPCClose", .{});
+    const this_jsvalue = this.this_jsvalue;
+    defer this_jsvalue.ensureStillAlive();
+    this_jsvalue.ensureStillAlive();
path: src/bun.js/api/bun/subprocess.zig
user: login: 190n
body: non-deferred one is redundant
created_at: 2025-04-03T00:53:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2588,10 +2622,15 @@ pub fn handleIPCClose(this: *Subprocess) void {
     }
     this.ipc_data = null;
 
-    const this_jsvalue = this.this_jsvalue;
-    this_jsvalue.ensureStillAlive();
-    if (this.on_disconnect_callback.trySwap()) |callback| {
-        this.globalThis.bunVM().eventLoop().runCallback(callback, this.globalThis, this_jsvalue, &.{JSValue.jsBoolean(ok)});
+    if (this_jsvalue != .zero) {
+        // Avoid keeping the callback alive longer than necessary
+        JSC.Codegen.JSSubprocess.ipcCallbackSetCached(this_jsvalue, globalThis, .zero);
+
+        // Call the onDisconnectCallback if it exists and prevent it from being kept alive longer than necessary
+        if (JSC.Codegen.JSSubprocess.onDisconnectCallbackGetCached(this_jsvalue)) |callback| {
+            JSC.Codegen.JSSubprocess.onDisconnectCallbackSetCached(this_jsvalue, globalThis, .zero);
path: src/bun.js/api/bun/subprocess.zig
user: login: 190n
body: can't this use a `fn consumeOnDisconnectCallback` like you wrote for the other ones
created_at: 2025-04-03T00:55:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2394,6 +2406,18 @@ pub fn spawnMaybeSync(
     var send_exit_notification = false;
 
     if (comptime !is_sync) {
+        bun.debugAssert(out != .zero);
+
+        if (on_exit_callback.isCell()) {
+            JSC.Codegen.JSSubprocess.onExitCallbackSetCached(out, globalThis, on_exit_callback);
+        }
+        if (on_disconnect_callback.isCell()) {
+            JSC.Codegen.JSSubprocess.onDisconnectCallbackSetCached(out, globalThis, on_disconnect_callback);
+        }
+        if (ipc_callback.isCell()) {
+            JSC.Codegen.JSSubprocess.ipcCallbackSetCached(out, globalThis, ipc_callback);
+        }
+
path: src/bun.js/api/bun/subprocess.zig
user: login: 190n
body: It looks like this code is only being run under `!is_sync` when previously the Strongs would be initialized no matter what. Are they not needed in sync mode?
created_at: 2025-04-03T01:09:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2394,6 +2406,18 @@ pub fn spawnMaybeSync(
     var send_exit_notification = false;
 
     if (comptime !is_sync) {
+        bun.debugAssert(out != .zero);
+
+        if (on_exit_callback.isCell()) {
+            JSC.Codegen.JSSubprocess.onExitCallbackSetCached(out, globalThis, on_exit_callback);
+        }
+        if (on_disconnect_callback.isCell()) {
+            JSC.Codegen.JSSubprocess.onDisconnectCallbackSetCached(out, globalThis, on_disconnect_callback);
+        }
+        if (ipc_callback.isCell()) {
+            JSC.Codegen.JSSubprocess.ipcCallbackSetCached(out, globalThis, ipc_callback);
+        }
+
path: src/bun.js/api/bun/subprocess.zig
user: login: Jarred-Sumner
body: yeah we don't support IPC or onExit callbacks in Bun.spawnSync
created_at: 2025-04-03T01:10:13Z
author_association: COLLABORATOR

PR comments:
user: login: robobun
created_at: 2025-04-02T23:15:48Z
body: <div><sup>Updated 7:07 PM PT - Apr 2nd, 2025</sup></div>

:x: @Jarred-Sumner, your commit 3c96d9651c03569600ea5a4f5490d46bce5d9401 has 2 failures in [`Build #14405`](https://buildkite.com/bun/bun/builds/14405):

<ul><li><a href="https://github.com/oven-sh/bun/blob/3c96d9651c03569600ea5a4f5490d46bce5d9401/test/js/bun/s3/s3.test.ts"><code>test/js/bun/s3/s3.test.ts</code></a> - timeout on <a href="https://buildkite.com/bun/bun/builds/14405#0195f93d-bd2b-4cb4-8a4c-52590381ac4f">🍎 14 x64</a></li>
<li><a href="https://github.com/oven-sh/bun/blob/3c96d9651c03569600ea5a4f5490d46bce5d9401/test/js/bun/s3/s3.test.ts"><code>test/js/bun/s3/s3.test.ts</code></a> - timeout on <a href="https://buildkite.com/bun/bun/builds/14405#0195f93d-bd25-4d82-814c-7644fb31f322">🍎 13 aarch64</a></li><li><a href="https://github.com/oven-sh/bun/blob/3c96d9651c03569600ea5a4f5490d46bce5d9401/test/js/bun/spawn/spawn.test.ts"><code>test/js/bun/spawn/spawn.test.ts</code></a> - 1 failing on <a href="https://buildkite.com/bun/bun/builds/14405#0195f93d-bd7e-4c5f-9091-3b609ac5185a">🐧 3.21 x64-baseline</a></li></ul>


<hr />


🧪 &nbsp; try this PR locally:

```zig
bunx bun-pr 18725
```


<!-- generated-comment id=oven-sh/bun#18725 -->
author_association: COLLABORATOR
user: login: Jarred-Sumner
created_at: 2025-04-03T01:32:37Z
body: Addressed feedback
author_association: COLLABORATOR