feat(session): serve requests without blocking a thread, pause and step
The app's end of the connection, independent of the transport, which reaches it through Detail::Exchange: one request's way back, safe from any thread and never blocking. - What needs only the session's own state (hello, status, list, log, pause, resume) is answered on the transport thread. What touches the app (calls, get, set, trace, screenshot) queues for the next Pump and runs on the app's thread between frames. Waits and steps reply from the Pump that sees them satisfied, events from the thread that emits them. No request ever holds a thread while it waits. - A request whose timeout passes before Pump is answered Timeout and is guaranteed not to have run, so an agent never wonders whether a command went through; an Async result arrives on the Pump it is ready; requests left when the Session ends are answered ShuttingDown. - Advance() for frame loops and Paused()/TakeSteps() for fixed-step loops hold what the app gates on them; pausing an app that gates nothing is refused instead of silently doing nothing. pause --when stops the frame its condition holds; pause --on-check stops before the next Advance in the frame a check fails, optionally raising SIGTRAP for an attached debugger. - A wait that times out says what its condition's paths were; a log follower gets matching entries in order, from the backlog on, and is dropped when it cannot keep up. - Request parameters go through Codec, so a mistyped request gets the same did-you-mean replies a mistyped command does. The session sets PR_SET_PTRACER_ANY so a debugger the agent starts can attach. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3500b73ecf
commit
bd64c44fc5
15 changed files with 1851 additions and 33 deletions
|
|
@ -103,8 +103,9 @@ export namespace Crafter::Debug::Detail {
|
|||
Resolved Resolve(std::string_view path) const;
|
||||
// Sets the watch the path names, or a part of its value: "fort1.core.pos.x"
|
||||
// reads the watch "fort1.core.pos", replaces x and writes it back.
|
||||
// Returns an error message, or nothing. Call on the Pump thread.
|
||||
std::optional<std::string> Set(std::string_view path, const Value& value) const;
|
||||
// Returns why not (UnknownPath, ReadOnly or InvalidParams), or
|
||||
// nothing. Call on the Pump thread.
|
||||
std::optional<Error> Set(std::string_view path, const Value& value) const;
|
||||
|
||||
// The closest known command, watch or sample name to `name`, for
|
||||
// "did you mean" in errors.
|
||||
|
|
|
|||
108
interfaces/Crafter.Debug-Session.cppm
Normal file
108
interfaces/Crafter.Debug-Session.cppm
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The session: the app's end of the debug connection.
|
||||
//
|
||||
// ThreadPool::Start();
|
||||
// Debug::Session debug({ .name = "MyApp" });
|
||||
// EventListener<void> pump(&window.onBeforeUpdate, [] { Debug::Pump(); });
|
||||
//
|
||||
// Requests arrive on transport threads and are never answered by blocking
|
||||
// one: what needs only the session's own state (status, the command list,
|
||||
// the log, pause, resume) is answered there and then; what touches the app
|
||||
// (calls, watches, traces, screenshots) waits in a queue for the next Pump,
|
||||
// which runs it on the app's own thread between frames; waits and steps are
|
||||
// answered by the Pump that sees them satisfied. A request that cannot run
|
||||
// before its timeout is answered Timeout and is guaranteed not to have run.
|
||||
//
|
||||
// Pause and step only stop what the app gates on them:
|
||||
//
|
||||
// while (acc >= dt && Debug::Advance()) { sim.Tick(); acc -= dt; } // frame loops
|
||||
// if (Debug::Paused()) { for (auto n = Debug::TakeSteps(); n > 0; --n) sim.Tick(); acc = 0; }
|
||||
// // fixed-step loops
|
||||
//
|
||||
// Rendering and Pump keep running while paused, so the agent can still read
|
||||
// watches, take screenshots and step.
|
||||
export module Crafter.Debug:Session;
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
|
||||
export namespace Crafter::Debug::Detail {
|
||||
// One request's way back to its client. A transport implements it; the
|
||||
// session calls it from transport threads, the Pump thread and whatever
|
||||
// thread logs, so it must be safe from any thread and must not block.
|
||||
class Exchange {
|
||||
public:
|
||||
virtual ~Exchange() = default;
|
||||
// The final reply, ending the exchange.
|
||||
virtual void Reply(const Value& reply) = 0;
|
||||
// One more object on a streaming exchange (`log --follow`). False
|
||||
// when the client is gone or too far behind; the session then drops it.
|
||||
virtual bool Stream(const Value& line) = 0;
|
||||
// Reply, then call `afterSent` once the reply has left: what
|
||||
// `quit --force` exits from.
|
||||
virtual void ReplyThen(const Value& reply, std::function<void()> afterSent) {
|
||||
Reply(reply);
|
||||
afterSent();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export namespace Crafter::Debug {
|
||||
struct SessionOptions {
|
||||
// What `crafter-debug ls` calls this instance; the executable's
|
||||
// file name when empty.
|
||||
std::string name;
|
||||
// 0 for any free port; the discovery file says which.
|
||||
std::uint16_t port = 0;
|
||||
// Also record what the app writes to stdout and stderr on the timeline.
|
||||
bool captureStdio = false;
|
||||
// Called on the Pump thread when an agent asks the app to quit,
|
||||
// after QuitRequested() turned true. Don't call std::exit from it:
|
||||
// close the window or end the loop, so the Session is destroyed
|
||||
// before main returns.
|
||||
std::function<void()> onQuit;
|
||||
// False: no transport and no discovery file; requests come only
|
||||
// through Handle. For tests.
|
||||
bool listen = true;
|
||||
};
|
||||
|
||||
// One per process; a second throws. Destroy it before
|
||||
// ThreadPool::Stop() and before main returns: it closes its connections,
|
||||
// and msquic waits for open ones at exit.
|
||||
class Session {
|
||||
public:
|
||||
explicit Session(SessionOptions options = {});
|
||||
~Session();
|
||||
Session(const Session&) = delete;
|
||||
Session& operator=(const Session&) = delete;
|
||||
|
||||
// One request, from a transport or a test. Never blocks; the reply
|
||||
// goes through `exchange`.
|
||||
void Handle(const Value& request, std::shared_ptr<Detail::Exchange> exchange);
|
||||
// What every request must carry.
|
||||
const std::string& Token() const;
|
||||
|
||||
struct State;
|
||||
|
||||
private:
|
||||
std::shared_ptr<State> state;
|
||||
};
|
||||
|
||||
// Once per frame, on the thread that owns the app's state: runs queued
|
||||
// requests, polls Async results, evaluates waits and conditions, records
|
||||
// traces, and counts the frame. Does nothing without a Session.
|
||||
void Pump();
|
||||
// For frame loops: false while paused, true once per step granted.
|
||||
bool Advance();
|
||||
// For fixed-step loops: whether the session is paused; see TakeSteps.
|
||||
bool Paused();
|
||||
// The steps granted since the last call, all at once.
|
||||
std::uint32_t TakeSteps();
|
||||
// Whether an agent asked the app to quit.
|
||||
bool QuitRequested();
|
||||
// Where `crafter-debug screenshot` gets a PNG: called on the Pump
|
||||
// thread, returning the bytes when they are ready (a frame or two later
|
||||
// for a GPU readback). See the README for the Crafter.Graphics version.
|
||||
void SetCapture(std::function<Async<std::vector<std::byte>>()> capture);
|
||||
}
|
||||
|
|
@ -87,6 +87,10 @@ export namespace Crafter::Debug::Detail {
|
|||
std::vector<EntryKind> kinds;
|
||||
};
|
||||
|
||||
// Whether one entry passes a query's filters (all but `last`), for a
|
||||
// follower that sees entries one at a time.
|
||||
bool Matches(const TimelineQuery& query, const TimelineEntry& entry);
|
||||
|
||||
struct CheckSite {
|
||||
std::string file;
|
||||
std::uint32_t line = 0;
|
||||
|
|
|
|||
55
interfaces/Crafter.Debug.Core-Protocol.cppm
Normal file
55
interfaces/Crafter.Debug.Core-Protocol.cppm
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The wire contract between an app and crafter-debug. A request is one JSON
|
||||
// object,
|
||||
//
|
||||
// {"token": "...", "method": "debug.get", "params": {...}, "timeoutMs": 30000}
|
||||
//
|
||||
// and its reply one JSON object, either {"result": ...} or
|
||||
// {"error": {"code": "...", "message": "..."}}, the envelope 3DForts' debug
|
||||
// API used. A streaming request (`debug.log` with follow) is answered by one
|
||||
// object per line instead. Methods named debug.* are the session's own; any
|
||||
// other method is a command the app registered.
|
||||
//
|
||||
// `debug.hello` is frozen: every version answers it the same way, so a
|
||||
// client can always learn the protocol and source of the app it reached
|
||||
// before relying on anything else.
|
||||
export module Crafter.Debug.Core:Protocol;
|
||||
import std;
|
||||
import :Value;
|
||||
|
||||
export namespace Crafter::Debug {
|
||||
// Bumped on any change to a request or reply a client relies on.
|
||||
inline constexpr std::uint32_t protocolVersion = 1;
|
||||
// The library's own version, which debug.hello reports.
|
||||
inline constexpr std::string_view libraryVersion = "0.1.0-dev";
|
||||
// The QUIC ALPN: frozen with debug.hello, so a mismatch is reported by
|
||||
// hello instead of failing the handshake.
|
||||
inline constexpr std::string_view alpn = "crafter-debug/1";
|
||||
|
||||
// The error codes the session itself answers with; app commands add
|
||||
// their own.
|
||||
namespace Codes {
|
||||
inline constexpr std::string_view unauthorized = "Unauthorized";
|
||||
inline constexpr std::string_view unknownMethod = "UnknownMethod";
|
||||
inline constexpr std::string_view invalidRequest = "InvalidRequest";
|
||||
inline constexpr std::string_view invalidParams = "InvalidParams";
|
||||
inline constexpr std::string_view timeout = "Timeout";
|
||||
inline constexpr std::string_view notPaused = "NotPaused";
|
||||
inline constexpr std::string_view pauseUnsupported = "PauseUnsupported";
|
||||
inline constexpr std::string_view noCapture = "NoCapture";
|
||||
inline constexpr std::string_view shuttingDown = "ShuttingDown";
|
||||
inline constexpr std::string_view exception = "Exception";
|
||||
inline constexpr std::string_view readOnly = "ReadOnly";
|
||||
inline constexpr std::string_view unknownPath = "UnknownPath";
|
||||
inline constexpr std::string_view conditionError = "ConditionError";
|
||||
}
|
||||
|
||||
Value MakeReply(Value result);
|
||||
Value MakeErrorReply(std::string_view code, std::string_view message);
|
||||
|
||||
// Standard base64 with padding, for screenshots in a JSON reply.
|
||||
std::string Base64Encode(std::span<const std::byte> bytes);
|
||||
std::optional<std::vector<std::byte>> Base64Decode(std::string_view text);
|
||||
}
|
||||
|
|
@ -9,3 +9,4 @@ export import :Value;
|
|||
export import :Reflect;
|
||||
export import :Codec;
|
||||
export import :Result;
|
||||
export import :Protocol;
|
||||
|
|
|
|||
|
|
@ -3,17 +3,19 @@
|
|||
|
||||
// Runtime debugging for AI agents: what an app imports. Commands, watches
|
||||
// and samples (:Registry), the timeline with Log, Check and Emit
|
||||
// (:Timeline), and the conditions waits and pauses use (:Condition), over
|
||||
// the core shared with the client. The session follows in a later phase.
|
||||
// (:Timeline), the conditions waits and pauses use (:Condition), and the
|
||||
// session that serves them with Pump, Advance and pause control (:Session),
|
||||
// over the core shared with the client.
|
||||
export module Crafter.Debug;
|
||||
export import Crafter.Debug.Core;
|
||||
export import :Registry;
|
||||
export import :Timeline;
|
||||
export import :Condition;
|
||||
export import :Session;
|
||||
import std;
|
||||
|
||||
export namespace Crafter::Debug {
|
||||
std::string_view Version() {
|
||||
return "0.0.0-dev";
|
||||
constexpr std::string_view Version() {
|
||||
return libraryVersion;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue