Difficulty: Hard · Puzzle 6 of 6 · Topic: Real Interview Rounds: End-to-End Debug Walkthrough
Interviewer: 'The scoreboard has a bounded drain, but when a response is genuinely lost the run does not fail at the end of the test with the residual ids; it sits until the global timeout.' Log tail: UVM_INFO @ 120000ns [SB] draining, 3 expected outstanding ... nothing ... UVM_FATAL @ 10000000ns [TIMEOUT] The simulation timed out Explain why the scoreboard's own timeout never reports, and fix it.
localparam time DRAIN_TIMEOUT = 2000ns;
function void phase_ready_to_end(uvm_phase phase);
if (phase.get_name() != "run" || exp_q.size() == 0) return;
phase.raise_objection(this, "scoreboard drain");
\`uvm_info("SB", $sformatf("draining, %0d expected outstanding", exp_q.size()), UVM_LOW)
fork begin
wait (exp_q.size() == 0);
#(DRAIN_TIMEOUT); // bug: sequential, after the wait
if (exp_q.size() != 0)
\`uvm_error("SB", $sformatf("drain timeout, %0d left", exp_q.size()))
phase.drop_objection(this, "scoreboard drain");
end join_none
endfunctionIn which order do the wait and the delay execute?
1) Symptom: lost response leads to a hang until the global timeout instead of a scoreboard error with residual ids.
2) Hypotheses: a leaked objection from the drain; the objection raised in the wrong phase (excluded: the log shows 'draining'); the timeout never being reached.
3) Confirm: +UVM_OBJECTION_TRACE shows the 'scoreboard drain' objection held by the SB; a log line placed after the wait never prints in the lost-response run.
4) Root cause: wait(exp_q.size()==0) and #DRAIN_TIMEOUT are written one after the other, so the delay starts only after the queue empties. If a response is lost, the wait blocks forever and the timeout code is unreachable (verified in simulation: the post-wait statement never executes). Even in the healthy case it adds a pointless 2us.
5) Fix: race them, in an isolated block: fork begin fork wait (exp_q.size() == 0); #(DRAIN_TIMEOUT); join_any disable fork; end join; then report any residual ids (list them from exp_q) and drop the objection on every path. Better, restart the timer whenever exp_q changes size so a slow but moving drain is not cut off.
6) Make it re-entry safe: phase_ready_to_end is called again after the objection drops (UVM 1.2 bounds this with max_ready_to_end_iter, 20 by default, as remembered). On the timeout path exp_q is still non-empty, so the fixed code would re-raise and report the timeout again. Add a bit drain_failed, set on timeout, and start the function with if (drain_failed) return; so the error is reported once and the run ends.Answer: Root cause: the timeout is written after the wait instead of in parallel with it, so when a response is lost the wait never returns and the timeout and the drop are never reached. Fix: race the wait against the timeout (fork..join_any inside an isolating fork begin..end join), then report residual ids and drop the objection on every path.
Checked by running the code in Verilator 5; the printed output supports the answer above. Plain-SV model of the drain: a wait followed by a delay never reaches its timeout, the join_any race reports once and the guard stops a repeat.
A timeout written sequentially after the thing it is meant to bound is no timeout at all. Test the timeout path explicitly by deleting a response in a directed test.
How would you prove in a test that the drain timeout itself works? Model answer: Add a negative test that deliberately swallows one response (a knob in the monitor or a DUT error injection), expects the scoreboard's 'drain timeout, N left' error with the residual ids exactly at the configured bound, and checks that the run then ends normally rather than at the global timeout. That last claim relies on the drain_failed re-entry guard, because UVM re-invokes phase_ready_to_end after the objection drops (bounded by max_ready_to_end_iter, default 20; medium confidence from memory of UVM 1.2).
The drain timeout never fires, so I look at where the timeout is written relative to the wait. The root cause is that the timeout is written after the wait instead of in parallel with it, so when a response is lost the wait never returns and neither the timeout nor the drop is reached. I confirm it by deleting a response in a directed test. The fix is to race the wait against the timeout with fork and join_any inside an isolating fork begin end join, then report residual ids and drop the objection on every path.