From aa8eab81be00747dfd6c0b567653c2dc3c59288d Mon Sep 17 00:00:00 2001 From: Jake Gaylor Date: Thu, 3 Sep 2026 02:33:18 -0400 Subject: [PATCH] feat!: take managoat_sandbox 0.2.0, and drop like the protocol really does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `managoat_sandbox` 0.2.0 changes what a command stream that closes without an exit frame looks like: `{:error, %{ref: ref}, :closed_before_exit}` rather than a synthesised `{:exit, %{ref: ref}, 0}` (managoat/managoat_sandbox#4). `Connection` needed no change, and that is worth saying out loud rather than leaving to be rediscovered: this protocol has no way for a session to end without an exit code. The daemon watches the process and reports what it exits with, so the only route to a missing exit is the connection going away — which already broadcasts `{:error, %{ref: ref}, :runner_disconnected}` to every subscriber. The adapter has never fabricated a zero. `FakeDaemon`'s `drop` instruction did, though. It emitted an exit frame with code 0, which passed the old conformance rule by faking exactly the thing the rule was wrong about. It now stops the socket, so the subscriber gets the real disconnect through `Connection.terminate/2`, and the conformance suite's closes-without-an-exit-frame test passes because the adapter does the right thing rather than because the fake pretends to. The socket stops `:normal`, not `:shutdown`: it is linked to whoever started the daemon, and a non-normal exit would take a test process with it. A script that uses `drop` is the last thing that daemon does, which the moduledoc now says. Verified by reverting: with `drop` emitting the old exit frame, the conformance suite's drop test fails. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016SwfnLHB6NXqB2t6ZCbBAd --- CHANGELOG.md | 18 +++++++++++++++ lib/managoat/runner/testing/fake_daemon.ex | 26 +++++++++++++++++++++- mix.exs | 4 ++-- mix.lock | 4 ++-- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15129f4..765a8ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,24 @@ the package ships without a bump fails the release gate. ## [Unreleased] +## [0.2.0] - 2026-09-03 + +### Changed + +- Takes `managoat_sandbox ~> 0.2.0`, where a command stream that closes + without an exit frame is `{:error, %{ref: ref}, :closed_before_exit}` + rather than a synthesised `{:exit, %{ref: ref}, 0}` + (managoat/managoat_sandbox#4). +- `Managoat.Runner.Connection` needed no change: a subscriber whose command + loses its exit already gets `{:error, %{ref: ref}, :runner_disconnected}`, + because the protocol has no way for a session to end without a code — the + daemon watches the process and reports what it exits with. The only route + to a missing exit is the connection going away. +- `Managoat.Runner.FakeDaemon`'s `drop` instruction says that instead of + faking it. It used to emit an exit frame with code 0; it now stops the + socket, so the subscriber gets the disconnect the real protocol would + give. A script that uses `drop` is the last thing that daemon does. + ## [0.1.0] - 2026-09-02 ### Added diff --git a/lib/managoat/runner/testing/fake_daemon.ex b/lib/managoat/runner/testing/fake_daemon.ex index c415efe..500d530 100644 --- a/lib/managoat/runner/testing/fake_daemon.ex +++ b/lib/managoat/runner/testing/fake_daemon.ex @@ -21,6 +21,16 @@ defmodule Managoat.Runner.FakeDaemon do run as real processes emitting real frames; sessions journal every frame and `attach` replays from byte zero. + `drop` is the one instruction that is not a session frame. The protocol + has no way for a session to end without an exit code — the daemon watches + the process and reports what it exits with — so the only way a subscriber + loses a command's exit is the connection going away. `drop` therefore + stops the socket, and the subscriber gets the + `{:error, %{ref: ref}, :runner_disconnected}` that `Connection`'s + teardown broadcasts, which is what `Managoat.Sandbox`'s closes-without-an- + exit-frame rule asks this adapter for. It ends the whole connection, so a + script that uses it is the last thing that daemon does. + `start/2` connects a daemon for `runner_id`; `stop/1` disconnects it (the socket process exits, so callers see the disconnected errors). It is the executable form of the protocol description in `Connection`'s moduledoc — @@ -135,6 +145,14 @@ defmodule Managoat.Runner.FakeDaemon do Enum.each(frames, &push(state, Map.put(&1, "replay_for", req_id))) daemon_loop(state) + :drop_connection -> + # `:normal` rather than `:shutdown`: the socket is linked to whoever + # started the daemon (a test process), and a non-normal exit would + # take it down. `Connection.terminate/2` runs either way, which is + # the point of the drop. + GenServer.stop(state.socket, :normal) + daemon_loop(state) + {:session_done, session_id, code} -> state = put_in(state, [:sessions, session_id, :exit], code) frame = %{"stream" => "exit", "session_id" => session_id, "code" => code} @@ -336,7 +354,7 @@ defmodule Managoat.Runner.FakeDaemon do {:stdout, d} -> emit(daemon, session_id, "stdout", d) {:stderr, d} -> emit(daemon, session_id, "stderr", d) {:exit, c} -> finish(daemon, session_id, c) - :drop -> finish(daemon, session_id, 0) + :drop -> drop(daemon) :stay -> stay(daemon, session_id) end) @@ -368,6 +386,12 @@ defmodule Managoat.Runner.FakeDaemon do send(daemon, {:session_done, session_id, code}) exit(:normal) end + + # No exit frame, ever: the connection goes instead. See the moduledoc. + defp drop(daemon) do + send(daemon, :drop_connection) + exit(:normal) + end end defmodule Managoat.Runner.FakeDaemon.Socket do diff --git a/mix.exs b/mix.exs index a91bf2a..a01fe29 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Managoat.Runner.MixProject do use Mix.Project - @version "0.1.0" + @version "0.2.0" @source_url "https://github.com/managoat/managoat_runner" def project do @@ -49,7 +49,7 @@ defmodule Managoat.Runner.MixProject do # (#1345): the umbrella resolves it the same way apps/fountain does, # and `mix hex.build` for this app succeeds inside the umbrella, which # is what lets it graduate next. - {:managoat_sandbox, "~> 0.1.0"}, + {:managoat_sandbox, "~> 0.2.0"}, # The WebSock behaviour only. The adapter that mounts a handler on a # Plug connection (websock_adapter) belongs to the host application. {:websock, "~> 0.5"}, diff --git a/mix.lock b/mix.lock index 23177ed..d65e3ba 100644 --- a/mix.lock +++ b/mix.lock @@ -15,14 +15,14 @@ "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "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"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, - "managoat_sandbox": {:hex, :managoat_sandbox, "0.1.0", "233352bff88d336d732b71c4e33b80808dff51ef876ee281122790c03acbeae3", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:sprites, "0.2.0", [hex: :sprites, repo: "hexpm", optional: false]}], "hexpm", "df7fb117538ff9612436ce63cefa00847649f00bd71ccae1129f86029d74ebc0"}, + "managoat_sandbox": {:hex, :managoat_sandbox, "0.2.0", "344cec023949faa6f7ee2fa2f6ede6ba3a5ab526fbcc0138eb914ebdcda03fb0", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:sprites, "0.2.2", [hex: :sprites, repo: "hexpm", optional: false]}], "hexpm", "a601b2194c967407bd8136dc952d1c94f2c69423a0107d48ce9ae4e1919b41a3"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, - "sprites": {:hex, :sprites, "0.2.0", "bc6aebb6ccd4229b0d930eaa45cf585666a81455361dfad8a3b8bdd575c1df2e", [:mix], [{:client_signals, "~> 0.4.4", [hex: :client_signals, repo: "hexpm", optional: false]}, {:gun, "~> 2.1", [hex: :gun, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "954b95bc8f4eeb37667c94e8400586e989df1a3e908f00a400e62c1bee063a12"}, + "sprites": {:hex, :sprites, "0.2.2", "6c8be38d76e0c3b453417fcd328fb8388514a79b54d8713ac24bc1d67913740b", [:mix], [{:client_signals, "~> 0.4.4", [hex: :client_signals, repo: "hexpm", optional: false]}, {:gun, "~> 2.1", [hex: :gun, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "aa5a2b258d260806a9abb288a97f97dad5311395584ce0836503c923bc4b97bb"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, }