Skip to content

Commit 79cdbe3

Browse files
authored
Add supervisor and pooling support (#45)
The new `Bun.Supervisor` module can start a pool of workers to execute JavaScript via Bun. This allows for efficient reuse of bun processes without the overhead of spawning new processes for each call.
1 parent 25a3484 commit 79cdbe3

File tree

11 files changed

+436
-2
lines changed

11 files changed

+436
-2
lines changed

lib/bun.ex

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
defmodule Bun do
22
# https://github.com/oven-sh/bun/releases
3-
@latest_version "1.2.2"
3+
@latest_version "1.3.0"
44
@min_windows_version "1.1.0"
55

66
@moduledoc """
@@ -183,7 +183,7 @@ defmodule Bun do
183183
raise "no arguments passed to bun"
184184
end
185185

186-
# `bun` subcommands can keep running as a zombie process even after closing the parent
186+
# `bun` subcommands can keep running as a zombie process even after closing the parent
187187
# Elixir process. The wrapper script monitors stdin to ensure that the bun process is closed.
188188
# Applied to "build" as well as "x" subcommands.
189189
defp run_bun_command([wrapped_subcommand | _] = args, opts)
@@ -211,6 +211,48 @@ defmodule Bun do
211211
run(profile, args)
212212
end
213213

214+
@doc """
215+
Starts a pool of bun workers for executing JavaScript modules.
216+
217+
See `Bun.Supervisor.start_link/1` for available options.
218+
219+
## Example
220+
221+
{:ok, _pid} = Bun.start_link()
222+
{:ok, result} = Bun.call("myModule.js", ["arg1", "arg2"])
223+
"""
224+
defdelegate start_link(opts \\ []), to: Bun.Supervisor
225+
226+
@doc """
227+
Stops the bun worker pool.
228+
229+
See `Bun.Supervisor.stop/1` for details.
230+
"""
231+
defdelegate stop(), to: Bun.Supervisor
232+
233+
@doc """
234+
Calls a JavaScript module with the given arguments using the worker pool.
235+
236+
See `Bun.Supervisor.call/3` for details on arguments and options.
237+
238+
## Example
239+
240+
{:ok, result} = Bun.call("myModule.js", ["arg1", "arg2"])
241+
{:ok, result} = Bun.call("myModule.js", [], timeout: 10_000)
242+
"""
243+
defdelegate call(module, args \\ [], opts \\ []), to: Bun.Supervisor
244+
245+
@doc """
246+
Calls a JavaScript module and raises on error.
247+
248+
See `Bun.Supervisor.call!/3` for details on arguments and options.
249+
250+
## Example
251+
252+
result = Bun.call!("myModule.js", ["arg1"])
253+
"""
254+
defdelegate call!(module, args \\ [], opts \\ []), to: Bun.Supervisor
255+
214256
def install do
215257
zip =
216258
fetch_body!(

lib/bun/supervisor.ex

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
defmodule Bun.Supervisor do
2+
@moduledoc """
3+
A pooled supervisor for managing bun processes.
4+
5+
Uses NimblePool to manage a pool of workers that can execute JavaScript
6+
code via bun. This allows for efficient reuse of bun processes without
7+
the overhead of spawning new processes for each call.
8+
9+
## Usage
10+
11+
# Start the pool
12+
{:ok, _pid} = Bun.Supervisor.start_link()
13+
14+
# Call a JavaScript module
15+
{:ok, result} = Bun.Supervisor.call("myModule.js", ["arg1", "arg2"])
16+
17+
# Call with options
18+
{:ok, result} = Bun.Supervisor.call("myModule.js", [], timeout: 10_000)
19+
20+
# Call and raise on error
21+
result = Bun.Supervisor.call!("myModule.js", ["arg1"])
22+
23+
# Stop the pool
24+
:ok = Bun.Supervisor.stop()
25+
"""
26+
27+
alias Bun.Supervisor.Worker
28+
29+
@pool_name __MODULE__
30+
31+
@doc """
32+
Returns a child specification for use in a supervision tree.
33+
34+
## Options
35+
36+
* `:pool_size` - Number of workers in the pool (default: `System.schedulers_online()`)
37+
* `:name` - Name to register the pool (default: `Bun.Supervisor`)
38+
39+
"""
40+
def child_spec(opts) do
41+
%{
42+
id: __MODULE__,
43+
start: {__MODULE__, :start_link, [opts]},
44+
type: :worker,
45+
restart: :permanent,
46+
shutdown: 5000
47+
}
48+
end
49+
50+
@doc """
51+
Starts the pool of bun workers.
52+
53+
## Options
54+
55+
* `:pool_size` - Number of workers in the pool (default: `System.schedulers_online()`)
56+
* `:name` - Name to register the pool (default: `Bun.Supervisor`)
57+
58+
"""
59+
def start_link(opts \\ []) do
60+
pool_size = Keyword.get(opts, :pool_size, System.schedulers_online())
61+
name = Keyword.get(opts, :name, @pool_name)
62+
63+
pool_opts = [
64+
worker: {Worker, []},
65+
pool_size: pool_size,
66+
name: name
67+
]
68+
69+
NimblePool.start_link(pool_opts)
70+
end
71+
72+
@doc """
73+
Stops the pool.
74+
"""
75+
def stop(name \\ @pool_name) do
76+
NimblePool.stop(name)
77+
end
78+
79+
@doc """
80+
Calls a JavaScript module with the given arguments.
81+
82+
## Arguments
83+
84+
* `module` - Path to the JavaScript module to execute
85+
* `args` - List of arguments to pass to the module (default: `[]`)
86+
* `opts` - Options for the call
87+
88+
## Options
89+
90+
* `:timeout` - Maximum time to wait for the call in milliseconds (default: `5000`)
91+
* `:pool` - Name of the pool to use (default: `Bun.Supervisor`)
92+
* `:cd` - Working directory for the command (default: `File.cwd!()`)
93+
* `:env` - Environment variables for the command (default: `%{}`)
94+
95+
## Returns
96+
97+
* `{:ok, result}` - The output from the JavaScript module
98+
* `{:error, reason}` - If the call failed
99+
100+
"""
101+
def call(module, args \\ [], opts \\ []) do
102+
timeout = Keyword.get(opts, :timeout, 5000)
103+
pool = Keyword.get(opts, :pool, @pool_name)
104+
105+
NimblePool.checkout!(
106+
pool,
107+
:checkout,
108+
fn _from, worker ->
109+
result = Worker.execute(worker, module, args, opts)
110+
{result, worker}
111+
end,
112+
timeout
113+
)
114+
end
115+
116+
@doc """
117+
Calls a JavaScript module and raises on error.
118+
119+
See `call/3` for details on arguments and options.
120+
121+
## Returns
122+
123+
The output from the JavaScript module.
124+
125+
## Raises
126+
127+
Raises if the call fails.
128+
"""
129+
def call!(module, args \\ [], opts \\ []) do
130+
case call(module, args, opts) do
131+
{:ok, result} -> result
132+
{:error, reason} -> raise "Bun call failed: #{inspect(reason)}"
133+
end
134+
end
135+
end

lib/bun/supervisor/worker.ex

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
defmodule Bun.Supervisor.Worker do
2+
@moduledoc false
3+
4+
@behaviour NimblePool
5+
6+
defstruct [:port]
7+
8+
@impl NimblePool
9+
def init_pool(opts) do
10+
{:ok, opts}
11+
end
12+
13+
@impl NimblePool
14+
def init_worker(_pool_state) do
15+
# Workers are stateless - we create a new port for each execution
16+
{:ok, %__MODULE__{port: nil}, _pool_state = []}
17+
end
18+
19+
@impl NimblePool
20+
def handle_checkout(:checkout, _from, worker, pool_state) do
21+
{:ok, worker, worker, pool_state}
22+
end
23+
24+
@impl NimblePool
25+
def handle_checkin(_client_state, _from, worker, pool_state) do
26+
{:ok, worker, pool_state}
27+
end
28+
29+
@impl NimblePool
30+
def terminate_worker(_reason, _worker, pool_state) do
31+
{:ok, pool_state}
32+
end
33+
34+
@doc false
35+
def execute(_worker, module, args, opts) do
36+
cd = Keyword.get(opts, :cd, File.cwd!())
37+
env = Keyword.get(opts, :env, %{})
38+
39+
# Build the command to execute the JavaScript module
40+
cmd_args = [module] ++ Enum.map(args, &to_string/1)
41+
42+
case System.cmd(
43+
Bun.bin_path(),
44+
cmd_args,
45+
cd: cd,
46+
env: env,
47+
stderr_to_stdout: true
48+
) do
49+
{output, 0} ->
50+
{:ok, String.trim(output)}
51+
52+
{output, exit_code} ->
53+
{:error, {exit_code, String.trim(output)}}
54+
end
55+
end
56+
end

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ defmodule Bun.MixProject do
4040

4141
defp deps do
4242
[
43+
{:nimble_pool, "~> 1.0"},
4344
{:ex_doc, ">= 0.0.0", only: :dev}
4445
]
4546
end

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
66
"makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
77
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
8+
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
89
}

test/bun_supervisor_test.exs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
defmodule Bun.SupervisorTest do
2+
use ExUnit.Case, async: true
3+
4+
@fixtures_path Path.expand("fixtures", __DIR__)
5+
6+
setup do
7+
# Start a pool for each test with a unique name
8+
pool_name = :"test_pool_#{System.unique_integer()}"
9+
{:ok, _pid} = Bun.Supervisor.start_link(name: pool_name)
10+
11+
on_exit(fn ->
12+
try do
13+
Bun.Supervisor.stop(pool_name)
14+
catch
15+
:exit, _ -> :ok
16+
end
17+
end)
18+
19+
{:ok, pool: pool_name}
20+
end
21+
22+
describe "start_link/1" do
23+
test "starts a pool with default options" do
24+
{:ok, pid} = Bun.Supervisor.start_link(name: :test_start_default)
25+
assert Process.alive?(pid)
26+
Bun.Supervisor.stop(:test_start_default)
27+
end
28+
29+
test "starts a pool with custom pool size" do
30+
{:ok, pid} = Bun.Supervisor.start_link(pool_size: 2, name: :test_start_custom)
31+
assert Process.alive?(pid)
32+
Bun.Supervisor.stop(:test_start_custom)
33+
end
34+
end
35+
36+
describe "call/3" do
37+
test "executes a simple JavaScript module", %{pool: pool} do
38+
hello_path = Path.join(@fixtures_path, "hello.js")
39+
assert {:ok, result} = Bun.Supervisor.call(hello_path, [], pool: pool)
40+
assert result == "Hello from bun!"
41+
end
42+
43+
test "executes a module with arguments", %{pool: pool} do
44+
hello_path = Path.join(@fixtures_path, "hello.js")
45+
assert {:ok, result} = Bun.Supervisor.call(hello_path, ["test", "args"], pool: pool)
46+
assert result == "test args"
47+
end
48+
49+
test "executes a module that performs computation", %{pool: pool} do
50+
add_path = Path.join(@fixtures_path, "add.js")
51+
assert {:ok, result} = Bun.Supervisor.call(add_path, ["5", "3"], pool: pool)
52+
assert result == "8"
53+
end
54+
55+
test "returns error tuple for failing module", %{pool: pool} do
56+
error_path = Path.join(@fixtures_path, "error.js")
57+
assert {:error, {exit_code, output}} = Bun.Supervisor.call(error_path, [], pool: pool)
58+
assert exit_code == 1
59+
assert output == "This is an error"
60+
end
61+
62+
test "respects timeout option", %{pool: pool} do
63+
hello_path = Path.join(@fixtures_path, "hello.js")
64+
65+
assert {:ok, _result} =
66+
Bun.Supervisor.call(hello_path, [], pool: pool, timeout: 10_000)
67+
end
68+
69+
test "works with custom working directory", %{pool: pool} do
70+
hello_path = "hello.js"
71+
72+
assert {:ok, result} =
73+
Bun.Supervisor.call(hello_path, [], pool: pool, cd: @fixtures_path)
74+
75+
assert result == "Hello from bun!"
76+
end
77+
end
78+
79+
describe "call!/3" do
80+
test "returns result on success", %{pool: pool} do
81+
hello_path = Path.join(@fixtures_path, "hello.js")
82+
assert result = Bun.Supervisor.call!(hello_path, [], pool: pool)
83+
assert result == "Hello from bun!"
84+
end
85+
86+
test "raises on error", %{pool: pool} do
87+
error_path = Path.join(@fixtures_path, "error.js")
88+
89+
assert_raise RuntimeError, ~r/Bun call failed/, fn ->
90+
Bun.Supervisor.call!(error_path, [], pool: pool)
91+
end
92+
end
93+
end
94+
95+
describe "stop/1" do
96+
test "stops the pool" do
97+
{:ok, _pid} = Bun.Supervisor.start_link(name: :test_stop)
98+
assert :ok = Bun.Supervisor.stop(:test_stop)
99+
end
100+
end
101+
102+
describe "concurrent execution" do
103+
test "handles multiple concurrent calls", %{pool: pool} do
104+
add_path = Path.join(@fixtures_path, "add.js")
105+
106+
tasks =
107+
for i <- 1..10 do
108+
Task.async(fn ->
109+
Bun.Supervisor.call(add_path, [to_string(i), to_string(i)], pool: pool)
110+
end)
111+
end
112+
113+
results = Task.await_many(tasks)
114+
115+
assert Enum.all?(results, fn
116+
{:ok, _} -> true
117+
_ -> false
118+
end)
119+
end
120+
end
121+
end

0 commit comments

Comments
 (0)