Skip to content

Commit afdb4ab

Browse files
authored
Merge pull request #105 from Jump-App/request-response-uid
Fix response cross-contamination via request-response correlation UIDs
2 parents cf8bf5a + 9cd32e6 commit afdb4ab

3 files changed

Lines changed: 83 additions & 22 deletions

File tree

lib/nodejs/worker.ex

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ defmodule NodeJS.Worker do
6060
]
6161
)
6262

63-
{:ok, [node_service_path(), port]}
63+
{:ok, %{service_path: node_service_path(), port: port, uid_counter: 0}}
6464
end
6565

6666
defp get_env_vars(module_path) do
@@ -70,29 +70,48 @@ defmodule NodeJS.Worker do
7070
]
7171
end
7272

73-
defp get_response(data, timeout) do
73+
defp get_response(data, timeout, port, expected_uid) do
7474
receive do
75-
{_port, {:data, {flag, chunk}}} ->
75+
{^port, {:data, {flag, chunk}}} ->
7676
data = data ++ chunk
7777

7878
case flag do
7979
:noeol ->
80-
get_response(data, timeout)
80+
get_response(data, timeout, port, expected_uid)
8181

8282
:eol ->
8383
case data do
84-
@prefix ++ protocol_data -> {:ok, protocol_data}
85-
_ -> get_response(~c"", timeout)
84+
@prefix ++ protocol_data ->
85+
case extract_uid(protocol_data) do
86+
{^expected_uid, response_data} ->
87+
{:ok, response_data}
88+
89+
{_stale_uid, _response_data} ->
90+
# Response from a different (likely timed-out) request — discard it
91+
get_response(~c"", timeout, port, expected_uid)
92+
end
93+
94+
_ ->
95+
get_response(~c"", timeout, port, expected_uid)
8696
end
8797
end
8898

89-
{_port, {:exit_status, status}} when status != 0 ->
99+
{^port, {:exit_status, status}} when status != 0 ->
90100
{:error, {:exit, status}}
91101
after
92102
timeout -> {:error, :timeout}
93103
end
94104
end
95105

106+
# Extracts the UID and response data from protocol data.
107+
# Protocol format: "uid:json_response"
108+
defp extract_uid(data) do
109+
case Enum.split_while(data, &(&1 != ?:)) do
110+
{uid_chars, [?: | rest]} -> {List.to_string(uid_chars), rest}
111+
_ -> {"", data}
112+
end
113+
end
114+
96115
defp decode_binary(data, binary) do
97116
if binary === true do
98117
:binary.list_to_bin(data)
@@ -102,15 +121,18 @@ defmodule NodeJS.Worker do
102121
end
103122

104123
@doc false
105-
def handle_call({module, args, opts}, _from, [_, port] = state)
124+
def handle_call({module, args, opts}, _from, %{port: port, uid_counter: uid_counter} = state)
106125
when is_tuple(module) do
107126
timeout = Keyword.get(opts, :timeout)
108127
binary = Keyword.get(opts, :binary)
109128
esm = Keyword.get(opts, :esm, false)
110-
body = Jason.encode!([Tuple.to_list(module), args, esm])
129+
uid = Integer.to_string(uid_counter)
130+
body = Jason.encode!([uid, Tuple.to_list(module), args, esm])
111131
Port.command(port, "#{body}\n")
112132

113-
case get_response(~c"", timeout) do
133+
state = %{state | uid_counter: uid_counter + 1}
134+
135+
case get_response(~c"", timeout, port, uid) do
114136
{:ok, response} ->
115137
decoded_response =
116138
response
@@ -169,7 +191,7 @@ defmodule NodeJS.Worker do
169191
end
170192

171193
@doc false
172-
def terminate(_reason, [_, port]) do
194+
def terminate(_reason, %{port: port}) do
173195
reset_terminal(port)
174196
send(port, {self(), :close})
175197
end

priv/server.js

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@ async function importModuleRespectingNodePath(modulePath) {
2424
for(const nodePath of NODE_PATHS) {
2525
// Try to resolve the module in the current path
2626
const modulePathToTry = path.join(nodePath, modulePath)
27-
if (fileExists(modulePathToTry)) {
27+
if (await fileExists(modulePathToTry)) {
2828
// imports are cached. To bust that cache, add unique query string to module name
2929
// eg NodeJS.call({"esm-module.mjs?q=#{System.unique_integer()}", :fn})
30-
// it will leak memory, so I'm not doing it by default!
30+
// it will leak memory, so I'm not doing it by default!
3131
// see more: https://ar.al/2021/02/22/cache-busting-in-node.js-dynamic-esm-imports/#cache-invalidation-in-esm-with-dynamic-imports
3232
return await import(modulePathToTry)
3333
}
3434
}
35-
35+
3636
throw new Error(`Could not find module '${modulePath}'. Hint: File extensions are required in ESM. Tried ${NODE_PATHS.join(", ")}`)
3737
}
3838

@@ -45,22 +45,28 @@ function getAncestor(parent, [key, ...keys]) {
4545
}
4646

4747
async function getResponse(string) {
48+
let uid = ""
4849
try {
49-
const [[modulePath, ...keys], args, useImport] = JSON.parse(string)
50+
const parsed = JSON.parse(string)
51+
uid = parsed[0]
52+
const [[modulePath, ...keys], args, useImport] = parsed.slice(1)
5053
const importFn = useImport ? importModuleRespectingNodePath : requireModule
51-
const mod = await importFn(modulePath)
54+
const mod = await importFn(modulePath)
5255
const fn = await getAncestor(mod, keys)
5356
if (!fn) throw new Error(`Could not find function '${keys.join(".")}' in module '${modulePath}'`)
5457
const returnValue = fn(...args)
5558
const result = returnValue instanceof Promise ? await returnValue : returnValue
56-
return JSON.stringify([true, result])
57-
} catch ({ message, stack }) {
58-
return JSON.stringify([false, `${message}\n${stack}`])
59+
return { uid, data: JSON.stringify([true, result]) }
60+
} catch (err) {
61+
const message = err?.message ?? String.valueOf(err)
62+
const stack = err?.stack ?? ""
63+
return { uid, data: JSON.stringify([false, `${message}\n${stack}`]) }
5964
}
6065
}
6166

6267
async function onLine(string) {
63-
const buffer = Buffer.from(`${await getResponse(string)}\n`)
68+
const { uid, data } = await getResponse(string)
69+
const buffer = Buffer.from(`${uid}:${data}\n`)
6470

6571
// The function we called might have written something to stdout without starting a new line.
6672
// So we add one here and write the response after the prefix

test/nodejs_test.exs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,39 @@ defmodule NodeJS.Test do
174174
end
175175
end
176176

177+
describe "request-response correlation" do
178+
test "stale responses from timed-out calls never leak into subsequent calls" do
179+
# Use a dedicated single-worker pool so all calls go to the same worker.
180+
# This guarantees the stale response lands in the same GenServer mailbox
181+
# that the follow-up call is waiting on.
182+
path = __ENV__.file |> Path.dirname() |> Path.join("js")
183+
184+
start_supervised!(
185+
Supervisor.child_spec(
186+
{NodeJS.Supervisor, path: path, name: NodeJS.RaceTest, pool_size: 1},
187+
id: NodeJS.RaceTest
188+
)
189+
)
190+
191+
# Send a call that takes 300ms but times out after 50ms.
192+
# After timeout, the worker is free but the Node.js response is still pending.
193+
assert {:error, "Call timed out."} =
194+
NodeJS.call("slow-async-echo", [9999, 300],
195+
timeout: 50,
196+
name: NodeJS.RaceTest
197+
)
198+
199+
# Immediately send a follow-up call that takes 500ms.
200+
# Its `receive` window overlaps with the stale response arriving at ~300ms.
201+
# Without UID correlation, `receive` would pick up the stale `9999` response.
202+
assert {:ok, 42} =
203+
NodeJS.call("slow-async-echo", [42, 500],
204+
timeout: 5_000,
205+
name: NodeJS.RaceTest
206+
)
207+
end
208+
end
209+
177210
describe "overriding call timeout" do
178211
test "works, and you can tell because the slow function will time out" do
179212
assert {:error, "Call timed out."} = NodeJS.call("slow-async-echo", [1111], timeout: 0)
@@ -243,12 +276,12 @@ defmodule NodeJS.Test do
243276

244277
test "fails if extension is not specified" do
245278
assert {:error, msg} = NodeJS.call({"esm-module", :hello}, ["me"], esm: true)
246-
assert js_error_message(msg) =~ "Cannot find module"
279+
assert msg =~ "find module"
247280
end
248281

249282
test "fails if file not found" do
250283
assert {:error, msg} = NodeJS.call({"nonexisting.js", :hello}, [], esm: true)
251-
assert js_error_message(msg) =~ "Cannot find module"
284+
assert msg =~ "find module"
252285
end
253286

254287
test "fails if file has errors" do

0 commit comments

Comments
 (0)