Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • Part 0 · What Debugging Is6
  • Part 1 · Reproduce8
    • Determinism and Seeds
    • Recording Everything to Replay
    • Shrinking a Failing Test
    • Reducing the Design
    • Making a Flaky Test Fail
    • When Simulation Cannot Reproduce It
    • Timeouts and Watchdogs in Benches
    • The Reproduction Checklist
  • Part 2 · Observe9
  • Part 3 · Localise10
  • Part 4 · Hypothesise and Test6
  • Part 5 · Bug Classes16
  • Part 6 · Debug by Stage9
  • Part 7 · Hangs and Deadlocks7
  • Part 8 · Intermittent Failures7
  • Part 9 · Fix, Prove, Prevent7
  • Part 10 · Case Studies12
  • Part 11 · Debug as a Team5
  • Part 12 · Interview Prep5
Loading lesson

‹Part 1 · Reproduce
  1. Topics
  2. The Art of Hardware Debug
  3. Part 1 · Reproduce
  4. Timeouts and Watchdogs in Benches

Part 1 · Reproduce · Working (W)

Timeouts and Watchdogs in Benches

Why a hung test should fail in thousands of cycles with a message, not after ten hours with none: global timeouts against progress watchdogs on dbglab's real deadlock, how to size both from measurements, the polling trap, and a small bench with both guards.

On this page

    Why this matters

    A test that hangs until the farm kills it after ten hours wastes a machine, delays the whole regression and leaves no message to debug. A progress watchdog turns the same hang into a FAIL line a few thousand cycles after it starts, naming what was waiting.

    After this lesson you can: give a bench a global timeout and a progress watchdog, define progress so that polling does not count, size both from measured runs, and write a watchdog message that starts the hang debug.

    Note

    Level: Working (W) · Debug-loop step: Reproduce

    Before this: Determinism and Seeds, The Debug Loop

    Ten hours, no message

    The overnight regression should take forty minutes. In the morning one job has been running for ten hours, until the farm's wall-clock limit killed it. Its log has one line, the test name and seed. No FAIL, no cycle, no hint of what was stuck. Worse, the rest of the regression waited behind it, so half the results are missing too.

    Something in that test stopped making progress early, maybe in its first microsecond of simulated time. Why did it take ten hours to find out, and why does the log say nothing?

    What people usually do, and why it fails

    Many benches rely on the farm's wall-clock kill, or on one global timeout set "high enough to be safe". A global timeout only knows how long the test has run, not whether anything is happening, so it must be longer than the longest healthy test, and every hang costs that much. When it fires, all it can say is "still running".

    The opposite mistake is a tight global timeout that kills healthy long tests. Then someone raises it, and the next hang costs more. Both are the same error: using a clock to detect a lack of progress.

    The method: two guards, sized from measurements

    1. A global timeout caps the whole test: a ceiling in cycles, sized from the longest legal run of that test with a generous margin. It catches anything the watchdog misses, including a livelock.

    2. A progress watchdog fails when nothing useful has happened for N cycles. N is sized from the longest legal quiet stretch, measured, times a margin.

    3. Define progress as finished work: a handshake that completed, a response that arrived, a word delivered. Do not count activity, such as clock edges or a bench polling a register.

    4. Give long waits their own limit, such as a maximum number of status polls, with a message that names the wait.

    5. Make the FAIL line start the debug. Print the cycle and the state of every interface that could be waiting, as in the deadlock case study.

    6. Count cycles, not seconds. Wall time changes with the machine and the simulator; a cycle count gives the same failure everywhere.

    7. Never raise a limit to make a test pass until you know why it fired.

    The picture: the same hang, three guards

    dbglab's deadlock (bug B2) stops all progress at cycle 165. The watchdog reports it at cycle 2,165; a global timeout alone reports it at 400,000 or 20,000,000 cycles, with no detail.

    All three bars are the same hang. Only the first one is useful: it arrives fastest and its message names the agent that was waiting.

    Do it: dbglab's two guards

    The guards in the bench

    design.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // snippet: tb/dbglab_tb.sv, lines 60-61 and 284-296
      always @(posedge clk)
        if (cycle >= max_cycles) fail($sformatf("TIMEOUT: still running after %0d cycles", max_cycles));
      ...
      int unsigned last_progress = 0;
      int          wd_words = 0;
      always @(posedge clk)
        if (rst_n) begin
          if ((dut.m_valid & dut.m_ready) != 2'b00 || dut.m_rvalid != 2'b00 || stream_words != wd_words) begin
            last_progress = cycle;
            wd_words = stream_words;
          end
          else if (cycle - last_progress > WD_LIMIT)
            fail($sformatf("WATCHDOG: no bus or stream progress for %0d cycles; cpu_valid=%b cpu_ready=%b cpu_addr=%08h",
                           WD_LIMIT, cpu_valid, cpu_ready, cpu_addr));
        end

    The global timeout is +MAX_CYCLES, 400,000 by default. Progress is a request accepted, a response, or a word out of the stream. WD_LIMIT is 2,000.

    The watchdog on a real hang

    Terminal
    $ /usr/bin/time -p tb/run.sh dma_burst_backpressure 1
    dbglab: test=dma_burst_backpressure seed=1
    FAIL test=dma_burst_backpressure seed=1 cycle=2165: WATCHDOG: no bus or stream progress for 2000 cycles; cpu_valid=1 cpu_ready=0 cpu_addr=00003008
    real 0.05
    ...

    The last progress was at cycle 165; the watchdog waited its 2,000 cycles and failed at 2165, in 0.05 seconds. The message already starts the hunt: the CPU is waiting (valid=1 ready=0) on a write to 0x3008.

    The same hang with only a global timeout

    In a scratch copy, set WD_LIMIT to 100,000,000 so the watchdog never fires:

    Terminal
    $ /usr/bin/time -p tb/run.sh dma_burst_backpressure 1
    dbglab: test=dma_burst_backpressure seed=1
    FAIL test=dma_burst_backpressure seed=1 cycle=400000: TIMEOUT: still running after 400000 cycles
    real 2.76
    ...
    $ time tb/run.sh dma_burst_backpressure 1 icarus +MAX_CYCLES=20000000
    dbglab: test=dma_burst_backpressure seed=1
    FAIL test=dma_burst_backpressure seed=1 cycle=20000000: TIMEOUT: still running after 20000000 cycles
    tb/run.sh dma_burst_backpressure 1 icarus +MAX_CYCLES=20000000  136.15s user 0.56s system 99% cpu 2:17.10 total

    Same bug, 185 times later and with no detail; raise the cap "to be safe" and it takes over two minutes. On a design a thousand times bigger, the same arithmetic gives the ten-hour job from the scene.

    Sizing the limits from measurements

    How long can a healthy dbglab test go without progress? Measure it with a probe that uses the watchdog's own definition:

    quiet_probe.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // not-run: needs dbglab's sources and bench; compile it next to them (command in the Evidence box)
    // quiet_probe: measures the longest stretch with no bus or stream progress, using the
    // watchdog's own definition of progress. It only reads the design and the bench.
    `timescale 1ns/1ps
    module quiet_probe;
      int unsigned last = 0, longest = 0, at = 0, words = 0;
      always @(posedge dbglab_tb.clk) begin
        #0.5;
        if (dbglab_tb.rst_n) begin
          if ((dbglab_tb.dut.m_valid & dbglab_tb.dut.m_ready) != 2'b00 || dbglab_tb.dut.m_rvalid != 2'b00
              || dbglab_tb.stream_words != words) begin
            if (dbglab_tb.cycle - last > longest) begin longest = dbglab_tb.cycle - last; at = dbglab_tb.cycle; end
            last = dbglab_tb.cycle;
            words = dbglab_tb.stream_words;
          end
        end
      end
      final $display("QUIET: longest stretch without progress = %0d cycles, ended at cycle %0d", longest, at);
    endmodule
    Terminal
    $ ../probes/run-probe.sh ../probes/quiet_probe.sv periph_stream 1 | grep -E "PASS|QUIET"
    PASS test=periph_stream seed=1 cycles=687 time=6866 ns
    QUIET: longest stretch without progress = 22 cycles, ended at cycle 449
    $ ../probes/run-probe.sh ../probes/quiet_probe.sv random 2 | grep -E "PASS|QUIET"
    PASS test=random seed=2 cycles=20526 time=205256 ns
    QUIET: longest stretch without progress = 52 cycles, ended at cycle 3683
    $ cd ../c14 && ../probes/run-probe.sh ../probes/quiet_probe.sv periph_power 1 | grep -E "PASS|QUIET"
    PASS test=periph_power seed=1
    QUIET: longest stretch without progress = 302 cycles, ended at cycle 393

    The longest legal quiet stretch found is 302 cycles, while the sink is powered down in periph_power (run at commit 14, before that test's own bug). A limit of 2,000 is about 6.6 times that: room for a slower peripheral, small enough to report a hang in microseconds of simulated time. The global timeout gets the same treatment: healthy random runs end at about 18,000 to 21,000 cycles, so 400,000 is about 20 times the longest healthy test.

    Too tight a cap fails healthy work, and the message looks just like a hang:

    Terminal
    $ tb/run.sh random 1 icarus +MAX_CYCLES=10000
    dbglab: test=random seed=1
    FAIL test=random seed=1 cycle=10000: TIMEOUT: still running after 10000 cycles

    Seed 1 passes at 19,972 cycles with the default cap. A TIMEOUT alone cannot tell a hang from a slow test; a watchdog can.

    The polling trap

    dbglab's watchdog counts any bus response as progress. What if the CPU is the one polling? In a scratch copy with B2 fixed, a test stops the sink, starts a paced 48-word copy into the peripheral and waits. The FIFO fills, the DMA waits for space forever, and the CPU reads STATUS again and again, every read a bus response:

    Terminal
    $ /usr/bin/time -p tb/run.sh poll_hang 1
    dbglab: test=poll_hang seed=1
    FAIL test=poll_hang seed=1 cycle=60136: DMA run #1: STATUS.busy still set after 20000 polls
    real 1.12
    ...
    $ /usr/bin/time -p tb/run.sh poll_hang 1 icarus +NOLIMIT
    dbglab: test=poll_hang seed=1
    FAIL test=poll_hang seed=1 cycle=400000: TIMEOUT: still running after 400000 cycles
    real 3.94
    ...

    The global watchdog never fires in either run. What saves the first run is the bench's own limit on a wait (dma_wait gives up after 20,000 polls) with a message that names the DMA run. Remove that limit, as the second run does, and only the global timeout is left. Note that random's final while (r_active) r_poll_dma(); loop in dbglab has no such limit.

    A small bench with both guards

    The same two guards in a bench you can run anywhere. The engine sends len words and then pulses done, but it decides it is finished only when a word moves, so a zero-length job never ends. One job in 200 is empty.

    copy_engine.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    // copy_engine: after start, sends len words on a valid/ready port, then pulses done.
    // The bug: done is decided only when a word is sent, so a zero-length job never ends.
    module copy_engine (
      input  logic       clk, rst_n, start,
      input  logic [3:0] len,
      output logic       valid,
      input  logic       ready,
      output logic       done
    );
      typedef enum logic [1:0] {IDLE, SEND, FIN} state_t;
      state_t     state;
      logic [3:0] sent, len_q;
      assign valid = (state == SEND) && (sent < len_q);
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n) begin
          state <= IDLE; sent <= '0; len_q <= '0; done <= 1'b0;
        end else begin
          done <= 1'b0;
          case (state)
            IDLE: if (start) begin state <= SEND; sent <= '0; len_q <= len; end
            SEND: if (valid && ready) begin
                    sent <= sent + 4'd1;
                    if (sent + 4'd1 == len_q) state <= FIN;   // only checked when a word moves
                  end
            FIN:  begin done <= 1'b1; state <= IDLE; end
            default: state <= IDLE;
          endcase
        end
    endmodule
    
    Terminal
    $ iverilog -g2012 -o w.vvp wd_tb.sv
    $ vvp -n w.vvp
    PASS: seed=1 40 jobs, 265 words, 440 cycles
    $ /usr/bin/time -p vvp -n w.vvp +SEED=4
    FAIL seed=4 cycle=611: WATCHDOG: no word and no done for 200 cycles; job 32 len=0 state=SEND sent=0 valid=0
    ...
    real 0.00
    ...
    $ /usr/bin/time -p vvp -n w.vvp +SEED=4 +NOWD
    FAIL seed=4 cycle=1000000: TIMEOUT: still running after 1000000 cycles
    ...
    real 2.02
    ...

    Read the watchdog line field by field. Job 32 has len=0, the engine sits in SEND with nothing sent and valid low, so no word can ever move: the bug is in that line. The TIMEOUT run reaches the same verdict about 1,600 times later and says only that it took too long. Verilator 5.048 prints the same lines and needs 0.20 s for the timeout run, a reminder that wall time depends on the simulator while cycles do not.

    What the evidence says

    On dbglab's deadlock and on the small bench, the watchdog turned a hang into a deterministic FAIL line at a known cycle, in a fraction of a second, with the state needed for the next step. That is what Reproduce needs from a hang: one command, a fixed failing cycle, and a message to start Observe from. The global timeout still earns its place, because the polling run shows a hang the watchdog cannot see.

    Traps

    Common pitfall

    Counting activity as progress. A bench that polls, a heartbeat counter, a clock that toggles: all look alive to a watchdog that counts them. Count completed work, and give every polling loop its own limit.

    Common pitfall

    Raising the limit to make it pass. When a watchdog fires on a test that "just needs longer", measure its quiet stretches first. If a legal stretch really is longer, raise the limit for that test with a comment saying why. If not, you have found a hang.

    Common pitfall

    A watchdog switched off for a long phase and never switched back. Benches often pause the watchdog during reset or a power-down. Make the pause end by itself after a bounded time, or the next hang in that phase is invisible.

    Why this matters

    Senior lens. Treat a TIMEOUT in the regression as a bug in the bench, even when the design is also wrong: it means a hang got past the watchdog. Ask which progress definition missed it and fix that, so the next one fails early with a message.

    Put the same idea in the design where it matters. A hardware watchdog or a bus timeout that returns an error turns a silicon hang into a status register the lab can read; that belongs to Part 7.

    Interview

    Asked: "Your regression has tests that hang until the farm kills them. How would you fix the bench?"

    Weak answer: I would add a global timeout to every test, maybe a million cycles, so they end sooner than the farm limit.

    Strong answer: Two guards. A global timeout per test, sized from the longest legal run with margin, as a backstop. And a progress watchdog that fails after N cycles with no completed transaction, where N comes from measuring the longest legal quiet stretch. Progress must be finished work, not polling, and every polling loop gets its own limit. The watchdog's message prints each interface's handshake state, so the failure line starts the hang debug. All limits are in cycles, so the failure is the same on every machine and simulator.

    Follow-ups: (1) What would you count as progress on a bus with posted writes? (2) How would you handle a test with a legal 50,000-cycle idle phase?

    Check yourself A colleague's test fails with the dbglab WATCHDOG after 2,000 idle cycles. They say the peripheral is just slow and want WD_LIMIT raised to 50,000. What do you ask first?

    What is the longest quiet stretch of a healthy run of this test? Measure it with the probe. If the peripheral legitimately idles 10,000 cycles, raise the limit for that test, with a comment. If healthy runs never idle more than a few hundred cycles, the watchdog has found a hang, and raising the limit would only make the next report 25 times slower and no more informative.

    Check yourself In wd_tb, someone changes the progress condition to if (valid || done). Would seed 4 still fail with a WATCHDOG, and why might this change be dangerous in another design?

    Seed 4 would still fail, because in the hang valid is 0. But valid alone is activity, not progress: a design whose valid stays high while the sink never accepts, a back-pressure deadlock, would look alive forever and only the global timeout would catch it. Count the handshake, valid && ready, or a completed job.

    Remember

    • A global timeout caps a whole test; a watchdog catches a test that stopped making progress. Every bench needs both, counted in cycles.

    • Progress means useful work finished, not activity. A bench that polls a status register forever looks busy to a watchdog that counts any bus response.

    • Size limits from measurements: the longest legal test and the longest legal quiet stretch, with margin. Never raise a limit to make a failure go away.

    Rule of thumb: a hang must end in a FAIL line that says what was waiting, within a few times the longest legal quiet stretch.

    How this was checked

    dbglab at HEAD 49c5f04 (rebuilt with dbglab/build-history.sh), Icarus Verilog 13.0, 2026-10-17. /usr/bin/time -p tb/run.sh dma_burst_backpressure 1: WATCHDOG at cycle 2165, real 0.05 s. In a copy with WD_LIMIT set to 100000000: TIMEOUT at cycle 400000, real 2.76 s; with +MAX_CYCLES=20000000, TIMEOUT at cycle 20000000, 2 min 17 s. tb/run.sh random 1 icarus +MAX_CYCLES=10000: TIMEOUT at cycle 10000 (the full run passes at 19972 cycles).

    quiet_probe.sv (shown) via iverilog -g2012 -Wno-timescale -I tb -o build/probe.vvp <rtl/files.f> tb/bus_rules.sv tb/dbglab_tb.sv quiet_probe.sv and vvp -n build/probe.vvp +TEST=<t> +SEED=<s>: longest quiet stretch 8 (smoke, dma_copy), 22 (periph_stream), 45 to 52 (random seeds 1 to 3); at commit 14 (b357bd8) periph_power 302 (passing), random and periph_stream seeds 1 to 8 at most 68.

    Polling trap: a copy with B2's fix (assign accept = rsp_fire;) and a scratch test poll_hang (sink off, a paced 48-word copy into the peripheral, then poll STATUS): tb/run.sh poll_hang 1 fails at cycle 60136 with the bench's 20000-poll limit (1.12 s); with the loop's limit removed (+NOLIMIT), TIMEOUT at cycle 400000 (3.94 s). No WATCHDOG in either.

    wd_tb as shown: iverilog -g2012, vvp -n w.vvp (PASS, seed 1), +SEED=4 (WATCHDOG at cycle 611), +SEED=4 +NOWD (TIMEOUT at 1000000, 2.02 s); Verilator 5.048 printed the same lines (0.20 s for the timeout run). Seeds 1 to 100: 21 fail. Rerun 2026-10-18 after giving ready its own generator. Limits: wall times are from one machine; cycle counts are the portable numbers.

    Keep going

    • Practice: Waveform debug: a write transaction timeout

    • Practice: Inline checkers: X detection and handshake liveness

    • Code lab: Testbench timeout

    • Code lab: Watchdog FSM

    • Interview: run a mock interview on this topic

    Related topics

    • Part 1 · Reproduce

    • Case study: the deadlock under back-pressure

    • Part 7: hangs and deadlocks

    • The reproduction checklist

    Previous

    13 / 107

    Next: The Reproduction Checklist

    Up next

    Up nextThe Reproduction Checklist

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Determinism and Seeds
    • Recording Everything to Replay
    • Shrinking a Failing Test