Inter-worker message bus and per-function call sampling - #1604
Open
elopez wants to merge 2 commits into
Open
Conversation
elopez
force-pushed
the
feat/interworker-bus
branch
from
August 14, 2026 16:02
ac2386b to
d1c7e87
Compare
gustavo-grieco
force-pushed
the
feat/interworker-bus
branch
from
August 14, 2026 17:54
d1c7e87 to
2e06348
Compare
A worker can now be asked to watch a handful of functions and keep, for each of them, how often it was called, how often the call did not return successfully, the range its return value spanned, and a short tail of revert summaries. This is what an outside observer needs to answer "is this function reachable, and what does it do when it is" without reading the corpus. The state hangs off WorkerState.sampledFunctions, keyed by canonical signature. Nothing populates that map yet -- the command that enables sampling arrives with the inter-worker bus -- so callseq's cost is a Map.null check per sequence, and the per-call work only happens for functions someone asked about. The interesting part is the per-event algebra, so it is factored out of the worker: applySampleEvent folds one call result into a SampleStats, abiCompare gives the partial ordering the range tracking needs, and mergeSampleStats combines two workers' views. All three are pure and covered by Tests.Sample; updateSampleStats in Worker/Sequence.hs is left with just the decode-and-dispatch. Every SampleStats field is strict, and the revert list is built with a take that forces its spine and elements. These are updated on the fuzzing hot path and only read when a client asks, so a lazy field would quietly retain the entire call history: a plain `take n (new : old)` keeps `old` alive through its unevaluated tail, and `old` keeps the one before it. Co-authored-by: gustavo-grieco <gustavo.grieco+github@gmail.com>
Workers have so far been able to communicate in exactly one direction: each pushes events onto the campaign's event queue, which the UI and the listener read. Nothing can talk back to a running worker, and a worker cannot tell another one what it just learned. Add a bus to Env alongside the event queue: a broadcast STM channel, so readers take their own dupTChan of it and every reader sees every message rather than racing for it. Echidna.Types.InterWorker holds the protocol -- a message, its sender, and the commands a fuzzing worker accepts. Two things use it. callseq broadcasts NewCoverageInfo whenever a sequence finds coverage, carrying the sequence and whether it came from replaying the corpus, which is the new argument threaded through callseq's callers. And a fuzzing worker checks the bus once per loop iteration for commands addressed to it, which is how sampling gets turned on and off. The two commands land here rather than with a sender because they are what makes the bus worth having; the client that issues them, and the listener for NewCoverageInfo, arrive with the MCP server. Everything else in the upstream protocol -- symbolic commands, request/response, the other broadcasts -- has neither a sender nor a reader, so it is left out until it has both. The command handler lives in its own module rather than in another where-block inside runFuzzWorker, which is the shape the preceding decomposition was aiming for and the natural home for the handlers that follow. Two details worth calling out: checkMessages drains its queue rather than taking one message per iteration. Every broadcast is copied to every worker's duplicate, so with N workers finding coverage a worker receives roughly N messages per iteration; consuming one would let the queue grow without bound during the early phase of a campaign, when almost every sequence is new coverage. Commands come from outside the campaign at human pace, so there is nothing to starve fuzzing with. The broadcast's sender is resolved through workerIDToType, the same way pushWorkerEvent decides which worker type to attribute an event to. The symbolic worker calls callseq too, and it is not a fuzzer. Co-authored-by: gustavo-grieco <gustavo.grieco+github@gmail.com>
elopez
force-pushed
the
feat/interworker-bus
branch
from
August 14, 2026 18:36
2e06348 to
7b32c00
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Workers can currently communicate in exactly one direction: each pushes events
onto the campaign's event queue, which the UI and the listener read. Nothing can
talk back to a running worker, and no worker can tell another one what it just
learned. Every remaining piece of #1502 needs that channel, so it lands here
together with the first thing that uses it.
What
feat: sample per-function call statisticsA worker can be asked to watch a handful of functions and keep, for each, how
often it was called, how often the call did not return successfully, the range
its return value spanned, and a short tail of revert summaries — what an outside
observer needs to answer "is this function reachable, and what does it do when
it is" without reading the corpus.
The state hangs off
WorkerState.sampledFunctions, keyed by canonicalsignature. The per-event algebra is factored out of the worker into
Types/Campaign.hs(applySampleEvent,abiCompare,mergeSampleStats) so itis pure and unit-testable;
updateSampleStatsinWorker/Sequence.hsis leftwith just decode-and-dispatch.
Tests.Samplecovers all three, 25 cases.feat: add an inter-worker message busEnvgains a broadcast STM channel alongside the event queue. Readers taketheir own
dupTChan, so a message reaches every reader instead of being racedfor by whoever gets there first.
Echidna.Types.InterWorkerholds the protocol.Two users:
callseqbroadcastsNewCoverageInfowhen a sequence findscoverage, carrying the sequence and whether it came from replaying the corpus
(the new argument threaded through
callseq's callers); and a fuzzing workerchecks the bus once per loop iteration for commands addressed to it, which is
how sampling is turned on and off.
Notes for review
NewCoverageInfoyet. Both clients are the MCP server, two rungs up.
Tests.Sampleis theentry point for the sampling algebra in the meantime. The two commands land
here rather than with their sender because they are what makes the bus worth
having.
request/response, and the other broadcasts have neither a sender nor a reader
on
dev-agents-3; each comes back with the code that uses it. (WorkerStoppedalso collided with
Types.Worker.WorkerEvent's constructor of the same name.)checkMessagesdrains its queue rather than handling one message periteration. Every broadcast is copied into every worker's duplicate, so with
N workers a worker receives roughly N messages per iteration; consuming one
would let the queue grow without bound during the early phase of a campaign,
when almost every sequence finds new coverage. Commands originate outside the
campaign at human pace, so draining cannot starve fuzzing.
workerIDToType, the same waypushWorkerEventdecides which worker type to attribute an event to. Thesymbolic worker calls
callseqtoo, and it is not a fuzzer.SampleStatsis load-bearing and was not free. Thesefields are updated on the fuzzing hot path and only read when a client asks,
so
take n (new : old)would keepoldalive through its unevaluated tailand
oldits predecessor — the list capped at five entries would haveretained the entire revert history. Hence
takeStrict, and the forced tuplecomponents in the range update.
where-blockinside
runFuzzWorker, matching what the preceding decomposition was aimingfor and giving the handlers in later rungs a home.
Verification
cabal build allandcabal build testsclean under-Wall -Wunused-packagescabal run tests— all 190 pass, symbolic tests included, so both a normalfuzzing run and a symbolic run still terminate
hlint lib src— clean (one pre-existing hint inSourceMapping.hs, untouchedhere)
ToFuzzer 0 (EnableSampling "bump(uint256)")written by any worker reachedworker 0 through its duplicate, workers 1–3 correctly ignored it, and worker 0
reported
sampleCalls = 2526, sampleReverts = 1603with a widened returnrange and exactly
maxRecentRevertssummaries retained.