A generator returning means items were accepted, not driven. Three closers exist: a bounded mailbox whose last put means acceptance, a per-item done flag, and a completion counter the env waits on.
The counter is the most explicit: the driver increments items_driven per item and the env waits for items_driven == items_generated. It serves any number of observers and doubles as an end-of-test statistic.
Signal | Means | Cost |
|---|---|---|
Bounded mailbox put returning | Driver accepted the item | Cheapest, slightly loose |
Per-item done flag | Driver finished the item | One flag plus a wait |
Completion counter | Exact driven count | Most wiring, most precise |
End of test evolves from #delay guesswork to transaction counts to objections. Any component raises while busy and drops when idle, and the test ends when the count stays zero through a drain window.
No lock is needed around the counter. raise and drop are functions, so they cannot block, and in practice no other process interleaves with them (a function cannot call the task semaphore::get anyway). What makes the scheme sound is one envelope objection that spans each run, so inter-item gaps never read as done, plus the drain window.
The class below keeps the count; the bench after it proves the envelope plus drain idea with plain counters.
timeunit 1ns; // one time unit for the classes and the module
timeprecision 1ps;
class objection_c;
local int unsigned count;
local string holders[$];
local time drain_time = 100;
function void raise(string who); // runs to completion: no lock needed
count++;
holders.push_back(who);
endfunction
function void drop(string who);
int at = -1;
count--;
for (int i = 0; i < holders.size(); i++)
if (holders[i] == who) at = i;
if (at >= 0) holders.delete(at);
endfunction
task wait_for_done();
forever begin
wait (count == 0);
#drain_time;
if (count == 0) return;
end
endtask
endclass
module top_obj;
objection_c obj = new();
initial begin
obj.raise("gen");
#50;
obj.drop("gen");
obj.wait_for_done();
if ($time != 150) $fatal(1, "FAIL: test ended at %0d ns, not after the 100 ns drain", $time);
$display("PASS: drop at 50 plus a 100 ns drain ended the test at 150");
$finish;
end
endmodulePASS: drop at 50 plus a 100 ns drain ended the test at 150One mailbox is FIFO, but puts from two producers in one timestep interleave by scheduler choice. Order across two mailboxes is never guaranteed, so a scoreboard must pend the early side by id instead of assuming expected beats actual.
Signal drives from testbench code need nonblocking assignment at clock edges, ideally through a clocking block. A blocking drive in the same timestep as a raw sampler leaves the outcome to scheduler order.
The barrier below makes two processes wait for each other. Its generation counter is the point: it keeps round two from corrupting round one.
class barrier_c;
local int unsigned parties;
local int unsigned arrived;
local int unsigned generation;
function new(int unsigned n);
parties = n;
endfunction
task arrive_and_wait();
int unsigned my_gen = generation;
arrived++;
if (arrived == parties) begin
arrived = 0;
generation++;
end
else wait (generation != my_gen);
endtask
endclass
module top_barrier;
timeunit 1ns;
timeprecision 1ps;
barrier_c b = new(2);
time first_released;
initial begin
fork
begin b.arrive_and_wait(); first_released = $time; end
begin #5; b.arrive_and_wait(); end // second party arrives late
join
if (first_released != 5) $fatal(1, "FAIL: first party left at %0d ns, before the second arrived", first_released);
$display("PASS: barrier held the first party until the second arrived at 5");
$finish;
end
endmodulePASS: barrier held the first party until the second arrived at 5That gap is normal pipelining, not idleness. A drain window with a re-check lets late consumers re-raise before the finish decision.
Arrival order across two boxes is unordered. When actual arrives first the pairing slips, so each side needs its own thread plus pending state keyed by id.
Four items with per-item raise and drop under one envelope, then a drain window with a re-check.
module tb_objection_envelope;
timeunit 1ns;
timeprecision 1ps;
int envelope = 1; // raised before time 0, so the checker cannot see a false idle
int outstanding = 0;
int completed = 0;
int errors = 0;
initial begin : producer
envelope = 1;
repeat (4) begin
wait (outstanding == 0);
outstanding++;
#5;
end
envelope = 0;
end
initial begin : consumer
repeat (4) begin
wait (outstanding == 1);
#10;
outstanding--;
completed++;
end
end
initial begin
wait (envelope == 0 && outstanding == 0);
#30;
if (envelope !== 0 || outstanding !== 0) errors++;
if (completed !== 4) errors++;
if (errors == 0) $display("PASS: 4-item run ended by envelope plus drain");
else $fatal(1, "FAIL: completed=%0d envelope=%0d", completed, envelope);
$finish;
end
endmodulePASS: 4-item run ended by envelope plus drainRun with Verilator 5.048 (--binary --timing) on 2026-10-18; every bench printed the PASS line shown, with no warnings. These class benches were run on Verilator only.