From 77929b80aa1eabad7ee3bb3d438833567782051f Mon Sep 17 00:00:00 2001 From: Peter Shoukry Date: Sat, 16 May 2026 20:40:35 +0300 Subject: [PATCH 1/3] fix: guard handle_sdk_message against non-map JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI may emit non-map JSON values (booleans, arrays, numbers, strings) on stdout when, for example, a hook callback fails and a Zod schema validation error is printed alongside the normal stream-JSON output. Control.classify/1 falls through to {:message, json} for any non-map, so without a guard the subsequent handle_sdk_message/2 clauses call json["type"], which invokes Access.get/3 on a non-map and crashes the Port GenServer with a FunctionClauseError — aborting the in-flight session. Add a guard clause that logs and drops the offending chunk so the session can continue. Covered by a MockCLI integration test that streams true, [1,2,3], and 42 between a system message and the final result and asserts the session still completes with the expected result. --- lib/claude_code/adapter/port.ex | 11 +++++ .../adapter/port_integration_test.exs | 43 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/lib/claude_code/adapter/port.ex b/lib/claude_code/adapter/port.ex index 852d5f8..d26a26e 100644 --- a/lib/claude_code/adapter/port.ex +++ b/lib/claude_code/adapter/port.ex @@ -580,6 +580,17 @@ defmodule ClaudeCode.Adapter.Port do end end + # The CLI may emit non-map JSON values (booleans, arrays, numbers, strings) on + # stdout when, for example, a hook callback fails and a Zod schema validation + # error is printed alongside the normal stream-JSON output. `Control.classify/1` + # falls through to `{:message, json}` for any non-map, so without this guard the + # subsequent clauses call `json["type"]` and crash the GenServer with a + # `FunctionClauseError` from `Access.get/3`, aborting the in-flight session. + defp handle_sdk_message(json, state) when not is_map(json) do + Logger.warning("Dropping non-map SDK message: #{inspect(json)}") + state + end + defp handle_sdk_message(json, %{current_request: nil} = state) do maybe_capture_session_id(json, state) end diff --git a/test/claude_code/adapter/port_integration_test.exs b/test/claude_code/adapter/port_integration_test.exs index 2e31ed8..43e18f5 100644 --- a/test/claude_code/adapter/port_integration_test.exs +++ b/test/claude_code/adapter/port_integration_test.exs @@ -54,6 +54,49 @@ defmodule ClaudeCode.Adapter.PortIntegrationTest do end end + describe "non-map JSON in stream" do + setup do + # CLI emits a non-map JSON line (`true`) between a normal system message + # and the final result. Without the `when not is_map(json)` guard in + # `handle_sdk_message/2`, the Port GenServer would crash with a + # `FunctionClauseError` from `Access.get(true, "type", nil)` and abort the + # in-flight session. + MockCLI.setup_with_script(""" + #!/bin/bash + while IFS= read -r line; do + if echo "$line" | grep -q '"type":"control_request"'; then + REQ_ID=$(echo "$line" | grep -o '"request_id":"[^"]*"' | cut -d'"' -f4) + echo "{\\\"type\\\":\\\"control_response\\\",\\\"response\\\":{\\\"subtype\\\":\\\"success\\\",\\\"request_id\\\":\\\"$REQ_ID\\\",\\\"response\\\":{}}}" + else + echo '{"type":"system","subtype":"init","cwd":"/test","session_id":"nonmap-test","tools":[],"mcp_servers":[],"model":"claude-3","permissionMode":"auto","apiKeySource":"ANTHROPIC_API_KEY"}' + echo 'true' + echo '[1,2,3]' + echo '42' + echo '{"type":"result","subtype":"success","is_error":false,"duration_ms":50,"duration_api_ms":40,"num_turns":1,"result":"survived","session_id":"nonmap-test","total_cost_usd":0.001,"usage":{}}' + fi + done + exit 0 + """) + end + + test "session survives and returns a result when CLI emits non-map JSON", %{ + mock_script: mock_script + } do + {:ok, session} = ClaudeCode.start_link(api_key: "test-key", cli_path: mock_script) + + messages = + session + |> ClaudeCode.stream("hello") + |> Enum.to_list() + + result = Enum.find(messages, &match?(%ResultMessage{}, &1)) + assert result != nil + assert result.result == "survived" + + GenServer.stop(session) + end + end + describe "stream completed normally" do setup do MockCLI.setup_with_script(""" From 68787fe105cb85ab3927efb4dc57500b519e4261 Mon Sep 17 00:00:00 2001 From: Peter Shoukry Date: Fri, 22 May 2026 21:20:45 +0300 Subject: [PATCH 2/3] fix: filter non-map JSON in process_line instead of handle_sdk_message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the guard one level up so non-map CLI output (booleans, arrays, numbers, strings) never reaches Control.classify/1 — which is spec'd to take a map and was failing dialyzer when passed a non-map. Also avoids misclassifying non-map values as SDK messages via the classify/1 catch-all. --- lib/claude_code/adapter/port.ex | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/lib/claude_code/adapter/port.ex b/lib/claude_code/adapter/port.ex index d26a26e..860a629 100644 --- a/lib/claude_code/adapter/port.ex +++ b/lib/claude_code/adapter/port.ex @@ -557,9 +557,14 @@ defmodule ClaudeCode.Adapter.Port do defp process_line("", state), do: state + # The CLI may emit non-map JSON values (booleans, arrays, numbers, strings) on + # stdout when, for example, a hook callback fails and a Zod schema validation + # error is printed alongside the normal stream-JSON output. Filter those out + # here so they never reach `Control.classify/1` (which is spec'd to take a map) + # or `handle_sdk_message/2` (whose clauses index with `json["type"]`). defp process_line(line, state) do case Jason.decode(line) do - {:ok, json} -> + {:ok, json} when is_map(json) -> case Control.classify(json) do {:control_response, msg} -> handle_control_response(msg, state) @@ -574,23 +579,16 @@ defmodule ClaudeCode.Adapter.Port do handle_sdk_message(json_msg, state) end + {:ok, non_map} -> + Logger.warning("Dropping non-map CLI output: #{inspect(non_map)}") + state + {:error, _} -> Logger.debug("Non-JSON CLI output: #{String.slice(line, 0, 500)}") state end end - # The CLI may emit non-map JSON values (booleans, arrays, numbers, strings) on - # stdout when, for example, a hook callback fails and a Zod schema validation - # error is printed alongside the normal stream-JSON output. `Control.classify/1` - # falls through to `{:message, json}` for any non-map, so without this guard the - # subsequent clauses call `json["type"]` and crash the GenServer with a - # `FunctionClauseError` from `Access.get/3`, aborting the in-flight session. - defp handle_sdk_message(json, state) when not is_map(json) do - Logger.warning("Dropping non-map SDK message: #{inspect(json)}") - state - end - defp handle_sdk_message(json, %{current_request: nil} = state) do maybe_capture_session_id(json, state) end From f0512acec028d9aec33fc4291ab56d8dfc5d88ed Mon Sep 17 00:00:00 2001 From: Peter Shoukry Date: Thu, 28 May 2026 16:56:01 +0300 Subject: [PATCH 3/3] test: assert specific log absence in filter_system_env to fix async flake `capture_log/2` installs a global Logger handler, so logs from concurrently running async tests (e.g. session_test.exs sending an invalid JSON `adapter_message` to provoke a warning) were leaking into these `assert log == ""` checks and failing CI nondeterministically. Refute only the specific "no matching system env" messages this code path can emit, so unrelated log noise from neighbouring tests no longer breaks the assertion. --- test/claude_code/adapter/port_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/claude_code/adapter/port_test.exs b/test/claude_code/adapter/port_test.exs index 434ea76..6b28c15 100644 --- a/test/claude_code/adapter/port_test.exs +++ b/test/claude_code/adapter/port_test.exs @@ -1741,7 +1741,7 @@ defmodule ClaudeCode.Adapter.PortTest do Port.filter_system_env(%{"PATH" => "/usr/bin"}, ["NONEXISTENT_VAR"]) end) - assert log == "" + refute log =~ "no matching system env" end test "no logging for :all mode even with debug" do @@ -1750,7 +1750,7 @@ defmodule ClaudeCode.Adapter.PortTest do Port.filter_system_env(%{"PATH" => "/usr/bin"}, :all, debug: true) end) - assert log == "" + refute log =~ "no matching system env" end end end