Skip to content

Commit 059a569

Browse files
committed
fix(s127): complete slice 127 (a test that times out says so)
collect/3 returned the same value whether it found what it was waiting for or gave up waiting: after remaining -> Enum.reverse(acc) So every assertion downstream reported a missing value rather than a timeout, and a reader goes looking for a logic defect that is not there. Opened from main's FIPS leg failing the slice 125 merge push (run 36253217084) while the pull_request run of the same commit passed. Not R26: no Exqlite error, no Database busy, no write path. Reproduced locally byte for byte by shortening one deadline from 15,000 ms to 300 ms. This trap cost a working day earlier in the same session. Slice 126 was opened on a test ending collect(id, &match?({:state, :idle}, &1), 15_000), where an idle broadcast from before the message under test was already in the mailbox; collect returned at once, every assertion ran mid-turn, and the result was a confident report of a product defect in the approval handshake that did not exist. await_event/3 raises, naming the deadline and every event it did see, because "waited 15s and saw thinking then idle" and "waited 15s and saw nothing at all" send a reader to opposite places. 20 asserting call sites migrated; the count came from the census rather than from reading. The census makes the old shape unwritable: the result of collect/3 may only be discarded. It enumerates from git ls-files, skips triple-quoted blocks and comments (without which it reported its own explanation of itself), asserts it enumerated more than 40 files so it cannot go blind, and was demonstrated against a planted violation. The deadline was sized from a measurement, not a hunch. Five instrumented runs gave 4,745 to 4,786 ms for the fork wait; the file's other two waits return from the mailbox in 0 to 3 ms and were left alone rather than raised on a hunch. 15,000 was 3.1x local and the leg still missed it, so 60,000. A generous deadline costs nothing on the passing path, because await_event/3 returns the instant the condition matches; the only thing it buys is how long a genuine hang takes to report. Named await_event/3 rather than await/3: Trinity.MCP.ServersUnderTest already exports await/2, and a default argument makes await/3 export an await/2 too, so every MCP test importing both failed to compile. Nothing under lib/ changed. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 46f886f commit 059a569

12 files changed

Lines changed: 133 additions & 27 deletions

‎test/session_case_test.exs‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ defmodule SessionCaseTest do
1313
"""
1414
use Trinity.DataCase, async: false
1515

16-
import Trinity.SessionCase, only: [collect: 3, await: 3]
16+
import Trinity.SessionCase, only: [collect: 3, await_event: 3]
1717

1818
alias Trinity.Factory
1919

@@ -29,7 +29,7 @@ defmodule SessionCaseTest do
2929
send(self(), {:session, id, {:state, :idle}})
3030

3131
message =
32-
assert_raise(RuntimeError, fn -> await(id, &match?({:forked, _}, &1), 200) end)
32+
assert_raise(RuntimeError, fn -> await_event(id, &match?({:forked, _}, &1), 200) end)
3333
|> Map.fetch!(:message)
3434

3535
assert message =~ "200ms", "the failure does not say how long it waited: #{message}"
@@ -43,7 +43,7 @@ defmodule SessionCaseTest do
4343
send(self(), {:session, id, {:state, :thinking}})
4444
send(self(), {:session, id, {:forked, "child-1"}})
4545

46-
events = await(id, &match?({:forked, _}, &1), 1_000)
46+
events = await_event(id, &match?({:forked, _}, &1), 1_000)
4747

4848
assert {:forked, "child-1"} = Enum.find(events, &match?({:forked, _}, &1))
4949
assert {:state, :thinking} in events
@@ -61,6 +61,67 @@ defmodule SessionCaseTest do
6161
end
6262
end
6363

64+
describe "AC3: the shape that caused this cannot be written again" do
65+
test "every call to collect/3 discards its result; anything asserting on one uses await/3" do
66+
{out, 0} = System.cmd("git", ["ls-files", "test/**/*.exs", "test/*.exs"])
67+
files = String.split(out, "\n", trim: true)
68+
69+
assert length(files) > 40,
70+
"only #{length(files)} test files enumerated; the census is blind"
71+
72+
# Only files that take the helper from Trinity.SessionCase. `llm_test.exs` defines a
73+
# `collect/1` of its own that has nothing to do with sessions.
74+
users =
75+
for f <- files,
76+
src = File.read!(f),
77+
src =~ "use Trinity.SessionCase" or src =~ "import Trinity.SessionCase",
78+
not (src =~ ~r/defp? collect\(/),
79+
do: {f, src}
80+
81+
assert length(users) > 15, "only #{length(users)} files use the helper; the census is blind"
82+
83+
violations =
84+
for {f, src} <- users,
85+
f != "test/session_case_test.exs",
86+
{line, n} <- code_lines(src),
87+
String.contains?(line, "collect("),
88+
not String.contains?(line, "_ = collect("),
89+
not String.contains?(line, "do_collect("),
90+
do: "#{f}:#{n}: #{String.trim(line)}"
91+
92+
assert violations == [],
93+
"""
94+
these call sites use the result of collect/3, which cannot tell a match from a \
95+
timeout, so a slow runner reports a missing value instead of a deadline. Use await_event/3, \
96+
which raises and names what it waited for:
97+
98+
#{Enum.join(violations, "\n")}
99+
"""
100+
end
101+
end
102+
103+
# Lines that are actually code: a `\"\"\"` block is documentation and a `#` line is a comment, and
104+
# naming the helper in prose is not calling it. Without this the census reports its own
105+
# explanation of itself.
106+
defp code_lines(src) do
107+
src
108+
|> String.split("\n")
109+
|> Enum.with_index(1)
110+
|> Enum.reduce({false, []}, fn {line, n}, {in_doc?, acc} ->
111+
fences =
112+
line
113+
|> String.graphemes()
114+
|> Enum.chunk_every(3, 1)
115+
|> Enum.count(&(&1 == ["\"", "\"", "\""]))
116+
117+
now_in_doc? = if rem(fences, 2) == 1, do: not in_doc?, else: in_doc?
118+
keep? = not in_doc? and not now_in_doc? and not String.starts_with?(String.trim(line), "#")
119+
{now_in_doc?, if(keep?, do: [{line, n} | acc], else: acc)}
120+
end)
121+
|> elem(1)
122+
|> Enum.reverse()
123+
end
124+
64125
describe "AC4: collect/3 still settles for the callers that do not care" do
65126
test "it returns what it saw and does not raise", %{id: id} do
66127
send(self(), {:session, id, {:state, :idle}})

‎test/support/session_case.ex‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,43 @@ defmodule Trinity.SessionCase do
5858
{:ok, pid}
5959
end
6060

61-
@doc "Collects session events for `session_id` until `until` matches or the timeout passes."
61+
@doc """
62+
Waits for a session event matching `until`, and **raises** if the deadline passes first.
63+
64+
Reach for this whenever the test asserts on what came back. `collect/3` below cannot tell a
65+
caller whether it matched or gave up, so an assertion after it reports a missing value rather
66+
than a timeout, and a reader goes looking for a logic defect that is not there (slice 127).
67+
68+
The raise names the deadline and every event that did arrive, because "waited 15s and saw
69+
thinking then idle" and "waited 15s and saw nothing at all" send a reader to opposite places.
70+
"""
71+
def await_event(session_id, until, timeout \\ 5_000) do
72+
deadline = System.monotonic_time(:millisecond) + timeout
73+
events = do_collect(session_id, until, deadline, [])
74+
75+
if Enum.any?(events, until) do
76+
events
77+
else
78+
raise """
79+
waited #{timeout}ms for a #{inspect(session_id)} event matching the given condition and it \
80+
never arrived.
81+
82+
Events seen, in order: #{inspect(events, limit: 30)}
83+
84+
An empty list means the session emitted nothing at all, which usually means it was never \
85+
started, the test never subscribed, or the thing that should have triggered it did not run. \
86+
A non-empty list means the session was working and did not reach this condition in time.
87+
"""
88+
end
89+
end
90+
91+
@doc """
92+
Collects session events until `until` matches **or the timeout passes**, returning what it saw.
93+
94+
A timeout and a match return the same shape, so this cannot be asserted on: use `await_event/3` for
95+
that. This is for settling, where the caller genuinely does not care whether the condition was
96+
reached, and a census in `test/session_case_test.exs` enforces that its result is discarded.
97+
"""
6298
def collect(session_id, until, timeout \\ 5_000) do
6399
deadline = System.monotonic_time(:millisecond) + timeout
64100
do_collect(session_id, until, deadline, [])

‎test/trinity/mcp/mrtr_test.exs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ defmodule Trinity.MCP.MrtrTest do
6868

6969
# The owner answers; the Session re-runs the call; the retry echoes the state and completes.
7070
{:ok, %Approval{answer: @answer}} = Permissions.decide_request(a2, :once, answer: @answer)
71-
events = collect(id, &match?({:state, :idle}, &1))
71+
events = await_event(id, &match?({:state, :idle}, &1))
7272

7373
assert {:assistant_message, %{content: "done "}} =
7474
Enum.find(events, &match?({:assistant_message, _}, &1))

‎test/trinity/memory/compaction_test.exs‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ defmodule Trinity.Memory.CompactionTest do
150150
repeats = div((soft + 600 - base) * 3, byte_size(unit))
151151
{:ok, _} = Session.send_user_message(pid, String.duplicate(unit, repeats))
152152

153-
events = collect(id, &match?({:state, :compacting}, &1), 5_000)
153+
events = await_event(id, &match?({:state, :compacting}, &1), 5_000)
154154

155155
assert :compacting in for({:state, s} <- events, do: s),
156156
"the kill needs the compacting state"
@@ -168,7 +168,7 @@ defmodule Trinity.Memory.CompactionTest do
168168
# The retry: the next message compacts once, cleanly.
169169
Fake.object_delay(0)
170170
{:ok, _} = Session.send_user_message(new_pid, "after the kill")
171-
events = collect(id, &match?({:state, :idle}, &1), 15_000)
171+
events = await_event(id, &match?({:state, :idle}, &1), 15_000)
172172
assert :compacting in for({:state, s} <- events, do: s)
173173
compactions = Enum.filter(Sessions.history(id, limit: 500), &Compactor.compaction?/1)
174174
assert length(compactions) == before + 1
@@ -193,7 +193,16 @@ defmodule Trinity.Memory.CompactionTest do
193193
Fake.script(script_deltas(1, "ok"))
194194
huge = String.duplicate("a message that no compaction can shrink ", 600)
195195
{:ok, _} = Session.send_user_message(pid, huge)
196-
events = collect(id, &match?({:forked, _}, &1), 15_000)
196+
# 60s, and the number is measured rather than picked. This wait is the only real one in this
197+
# file: locally it takes 4,745 to 4,786 ms over five runs, where the two waits above return
198+
# from the mailbox in 0 to 3 ms. The old 15,000 was 3.1x the local figure and the FIPS leg
199+
# still missed it (run 36253217084), which is what opened slice 127.
200+
#
201+
# A generous deadline costs nothing when the event arrives, because `await_event/3` returns
202+
# the instant the condition matches; the only thing it buys is how long a genuine hang takes
203+
# to report. So the trade is "long enough that a slow runner never trips it", and 12.6x the
204+
# local median is that.
205+
events = await_event(id, &match?({:forked, _}, &1), 60_000)
197206
assert {:forked, child_id} = Enum.find(events, &match?({:forked, _}, &1))
198207
assert :compacting in for({:state, s} <- events, do: s)
199208

‎test/trinity/permissions/session_flow_test.exs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ defmodule Trinity.Permissions.SessionFlowTest do
3939
tool_turn([{"c1", "echo", %{"text" => "plain"}}])
4040
{:ok, pid} = start_drained(id)
4141
{:ok, _} = Session.send_user_message(pid, "go")
42-
events = collect(id, &match?({:state, :idle}, &1))
42+
events = await_event(id, &match?({:state, :idle}, &1))
4343
refute :approval_wait in for({:state, s} <- events, do: s)
4444
refute_received {:approval, _, _}
4545
assert Permissions.list_approvals(session_id: id) == []
@@ -61,7 +61,7 @@ defmodule Trinity.Permissions.SessionFlowTest do
6161
assert [%Approval{id: ^aid}] = Permissions.pending(id)
6262

6363
{:ok, _} = Permissions.decide_request(aid, :once)
64-
events = collect(id, &match?({:state, :idle}, &1))
64+
events = await_event(id, &match?({:state, :idle}, &1))
6565

6666
assert {:assistant_message, %{content: "done done "}} =
6767
Enum.find(events, &match?({:assistant_message, _}, &1))
@@ -83,7 +83,7 @@ defmodule Trinity.Permissions.SessionFlowTest do
8383
# The same call again: no request.
8484
tool_turn([{"c1", "write_note", @args}])
8585
{:ok, _} = Session.send_user_message(pid, "two")
86-
events = collect(id, &match?({:state, :idle}, &1))
86+
events = await_event(id, &match?({:state, :idle}, &1))
8787
refute :approval_wait in for({:state, s} <- events, do: s)
8888
refute_received {:approval, :requested, _}
8989
assert length(tool_rows(id)) == 2
@@ -112,7 +112,7 @@ defmodule Trinity.Permissions.SessionFlowTest do
112112
{:ok, _} = Session.send_user_message(pid, "write")
113113
assert_receive {:approval, :requested, %Approval{id: aid}}, 2_000
114114
{:ok, _} = Permissions.decide_request(aid, :deny)
115-
events = collect(id, &match?({:state, :idle}, &1))
115+
events = await_event(id, &match?({:state, :idle}, &1))
116116

117117
assert Enum.any?(
118118
events,

‎test/trinity/sessions/crash_test.exs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ defmodule Trinity.Sessions.CrashTest do
5353
assert {:turn_interrupted,
5454
%Message{id: ^draft_id, parts: %{"interrupted" => true, "draft" => false}}} =
5555
Enum.find(
56-
collect(a.id, &match?({:turn_interrupted, _}, &1), 2_000),
56+
await_event(a.id, &match?({:turn_interrupted, _}, &1), 2_000),
5757
&match?({:turn_interrupted, _}, &1)
5858
)
5959

‎test/trinity/sessions/many_sessions_test.exs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ defmodule Trinity.Sessions.ManySessionsTest do
2121
fn r ->
2222
:ok = Sessions.subscribe(r.id)
2323
{:ok, _} = Sessions.send_user_message(r.id, "hi")
24-
events = collect(r.id, &match?({:assistant_message, _}, &1), 30_000)
24+
events = await_event(r.id, &match?({:assistant_message, _}, &1), 30_000)
2525
{r.id, match?({:assistant_message, _}, List.last(events))}
2626
end,
2727
max_concurrency: @n,

‎test/trinity/sessions/session_test.exs‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ defmodule Trinity.Sessions.SessionTest do
3636
{:ok, pid} = start_drained(id)
3737
assert {:ok, %Message{seq: 1, role: "user"}} = Session.send_user_message(pid, "hello")
3838

39-
events = collect(id, &match?({:assistant_message, _}, &1))
39+
events = await_event(id, &match?({:assistant_message, _}, &1))
4040
assert Enum.all?(events, &Events.valid?/1)
4141

4242
assert {:user_message, %Message{content: "hello"}} =
@@ -75,7 +75,7 @@ defmodule Trinity.Sessions.SessionTest do
7575
Fake.script(script_deltas(1_000, "y"))
7676
{:ok, pid} = start_drained(id)
7777
{:ok, _} = Session.send_user_message(pid, "go")
78-
events = collect(id, &match?({:assistant_message, _}, &1))
78+
events = await_event(id, &match?({:assistant_message, _}, &1))
7979
deltas = for {:assistant_delta, s} <- events, do: s
8080
assert length(deltas) <= 25, "got #{length(deltas)} delta broadcasts"
8181
assert Enum.join(deltas) == String.duplicate("y", 1_000)
@@ -91,7 +91,7 @@ defmodule Trinity.Sessions.SessionTest do
9191
Fake.scripts([nil, script_deltas(3, "final ")] |> Enum.map(&(&1 || default_script())))
9292
{:ok, pid} = start_drained(id)
9393
{:ok, _} = Session.send_user_message(pid, "weather?")
94-
events = collect(id, fn e -> match?({:state, :idle}, e) and true end, 8_000)
94+
events = await_event(id, fn e -> match?({:state, :idle}, e) and true end, 8_000)
9595
states = for {:state, s} <- events, do: s
9696
assert :tool_wait in states
9797
assert {:tool_call, %{id: "call_1", name: "get_weather"}} in events
@@ -137,7 +137,7 @@ defmodule Trinity.Sessions.SessionTest do
137137
Fake.fail(1, Trinity.LLM.Error.permanent(:boom))
138138
{:ok, pid} = start_drained(id)
139139
{:ok, _} = Session.send_user_message(pid, "hi")
140-
events = collect(id, &match?({:state, :idle}, &1))
140+
events = await_event(id, &match?({:state, :idle}, &1))
141141
states = for {:state, s} <- events, do: s
142142
assert :error in states
143143
assert {:error, _} = Enum.find(events, &match?({:error, _}, &1))
@@ -152,14 +152,14 @@ defmodule Trinity.Sessions.SessionTest do
152152
assert {:ok, _} = Session.send_user_message(pid, "again")
153153

154154
assert {:assistant_message, _} =
155-
List.last(collect(id, &match?({:assistant_message, _}, &1)))
155+
List.last(await_event(id, &match?({:assistant_message, _}, &1)))
156156
end
157157

158158
test "a Task that raises is an error turn too", %{id: id} do
159159
Fake.script([{:text_delta, "partial "}, :raise_now])
160160
{:ok, pid} = start_drained(id)
161161
{:ok, _} = Session.send_user_message(pid, "hi")
162-
events = collect(id, &match?({:state, :idle}, &1))
162+
events = await_event(id, &match?({:state, :idle}, &1))
163163
assert :error in for({:state, s} <- events, do: s)
164164

165165
assert [
@@ -191,7 +191,7 @@ defmodule Trinity.Sessions.SessionTest do
191191

192192
assert {:turn_interrupted, %Message{content: "start ", parts: %{"interrupted" => true}}} =
193193
Enum.find(
194-
collect(id, &match?({:turn_interrupted, _}, &1), 1_000),
194+
await_event(id, &match?({:turn_interrupted, _}, &1), 1_000),
195195
&match?({:turn_interrupted, _}, &1)
196196
)
197197

‎test/trinity/tools/runner_test.exs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ defmodule Trinity.Tools.RunnerTest do
4545
{t, {:ok, _}} = :timer.tc(fn -> Session.send_user_message(pid, "go") end)
4646
_ = t
4747
started = System.monotonic_time(:millisecond)
48-
events = collect(id, &match?({:state, :idle}, &1), 10_000)
48+
events = await_event(id, &match?({:state, :idle}, &1), 10_000)
4949
elapsed = System.monotonic_time(:millisecond) - started
5050
{events, elapsed, Sessions.history(id)}
5151
end

‎test/trinity_web/live/approval_live_test.exs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ defmodule TrinityWeb.ApprovalLiveTest do
66
@moduletag :capture_log
77

88
import Phoenix.LiveViewTest
9-
import Trinity.SessionCase, only: [script_deltas: 2, collect: 3]
9+
import Trinity.SessionCase, only: [script_deltas: 2, collect: 3, await_event: 3]
1010

1111
alias Trinity.Factory
1212
alias Trinity.LLM.Providers.Fake
@@ -84,7 +84,7 @@ defmodule TrinityWeb.ApprovalLiveTest do
8484
# Under the rule the next call runs without a card.
8585
write_turn()
8686
send_message(view, "again")
87-
events = collect(id, &match?({:state, :idle}, &1), 5_000)
87+
events = await_event(id, &match?({:state, :idle}, &1), 5_000)
8888
refute :approval_wait in for({:state, s} <- events, do: s)
8989

9090
# A call outside the rule asks, and Deny records the denial.

0 commit comments

Comments
 (0)