The gap
morph has executor doubles for the delivery order of continuations, but nothing that substitutes for the worker side of an async job. Every async-job test therefore runs a real thread pool and waits on wall-clock time.
What exists:
| double |
where |
what it controls |
DeterministicExecutor |
examples/common/testkit/strand_interleaver.hpp, tests/test_support.hpp |
the order in which queued continuations are delivered |
StepExecutor |
tests/test_support.hpp |
stepping continuation delivery |
InlineExecutor |
tests/test_support.hpp |
running a continuation inline |
All of these sit on the callback side. None of them lets a test say "the background job has now run, and not before".
What it costs today
examples/ledger/tests/test_ledger_reports.cpp has to poll:
/// Polls @p model's `GetReportStatus` for @p jobId until it leaves `Pending`,
/// or until the hard iteration cap is reached.
/// ... the real `ThreadPoolExecutor` and sleeps between polls.
status = model.execute(ledger::GetReportStatus{.jobId = jobId});
if (status.status != ledger::ReportStatus::Pending) { break; }
std::this_thread::sleep_for(std::chrono::milliseconds(10));
Three properties follow, none of them good:
- Wall-clock cost on every run, paid whether or not anything is wrong.
- A flakiness budget: the iteration cap is a guess about how slow a machine may be. This repository has already been bitten by exactly that shape — morph#147 was a
pumpUntil budget that was fine on CI runners and failed deterministically on a slower machine.
- Unobservable intermediate states. "Submitted, still
Pending, worker has not run yet" cannot be asserted deterministically; the test can only sample and hope.
examples/ledger/tests/test_report_job_poller.cpp shows the same shape from the other side — REQUIRE_FALSE(pumpUntil([] { return false; }, 120ms)) to assert a non-event, which is 120 ms of sleeping per assertion.
What is wanted
An IExecutor that queues tasks and runs them only when told:
ManualExecutor worker;
// ... submit the job ...
CHECK(status == Pending); // asserted, not sampled: the worker has not run
CHECK(worker.runOne());
CHECK(status == Done); // no sleep, no cap, no flakiness budget
It should also pick up tasks posted by a task, so a chained job runs to completion rather than stranding its own continuation. Not thread-safe by design — a test reasoning about exact task ordering drives it from one thread.
This is roughly 30 lines. A version was written as part of #154 (now closed) and can be lifted from that branch; it does not depend on anything else in that PR.
Where it belongs
The testkit, not include/morph/. Per #129, background jobs live at the App/server layer, so it is the App layer's tests that need to drive a worker deterministically. examples/common/testkit/ alongside the existing doubles is the natural home; tests/test_support.hpp if a framework-level test wants it too.
Context
Split out of #129, which was closed on the architectural point that models should not know about the framework. This part survives that decision: moving background jobs to the App layer relocates the testing gap rather than closing it. Related: #160 (ledger's own background job, which is the concrete case).
The gap
morph has executor doubles for the delivery order of continuations, but nothing that substitutes for the worker side of an async job. Every async-job test therefore runs a real thread pool and waits on wall-clock time.
What exists:
DeterministicExecutorexamples/common/testkit/strand_interleaver.hpp,tests/test_support.hppStepExecutortests/test_support.hppInlineExecutortests/test_support.hppAll of these sit on the callback side. None of them lets a test say "the background job has now run, and not before".
What it costs today
examples/ledger/tests/test_ledger_reports.cpphas to poll:Three properties follow, none of them good:
pumpUntilbudget that was fine on CI runners and failed deterministically on a slower machine.Pending, worker has not run yet" cannot be asserted deterministically; the test can only sample and hope.examples/ledger/tests/test_report_job_poller.cppshows the same shape from the other side —REQUIRE_FALSE(pumpUntil([] { return false; }, 120ms))to assert a non-event, which is 120 ms of sleeping per assertion.What is wanted
An
IExecutorthat queues tasks and runs them only when told:It should also pick up tasks posted by a task, so a chained job runs to completion rather than stranding its own continuation. Not thread-safe by design — a test reasoning about exact task ordering drives it from one thread.
This is roughly 30 lines. A version was written as part of #154 (now closed) and can be lifted from that branch; it does not depend on anything else in that PR.
Where it belongs
The testkit, not
include/morph/. Per #129, background jobs live at the App/server layer, so it is the App layer's tests that need to drive a worker deterministically.examples/common/testkit/alongside the existing doubles is the natural home;tests/test_support.hppif a framework-level test wants it too.Context
Split out of #129, which was closed on the architectural point that models should not know about the framework. This part survives that decision: moving background jobs to the App layer relocates the testing gap rather than closing it. Related: #160 (ledger's own background job, which is the concrete case).