Part 2 · OOP for Verification · Intermediate

disable fork, wait fork & process

disable fork scoping dangers, isolation with nested fork begin..end, wait fork, and std::process control.

disable fork: a chainsaw, not a scalpel

disable fork kills every child process spawned by the current process that is still running — not just the children of the most recent fork statement. If your task spawned a background monitor with join_none earlier and later uses join_any plus disable fork for a timeout race, the disable kills the monitor too. This sibling-killing behavior is the single most common source of "my monitor silently stopped mid-test" bugs.

diagram
DISABLE FORK BLAST RADIUS

  task run();                              With ISOLATION wrapper:
    fork  mon.run();  join_none  ─ M       fork begin                ┐
    ...                                      fork                    │ inner
    fork                                       main_seq();  ─ S      │ parent
      main_seq();   ─ S                        #1ms timeout ─ T      │ P'
      #1ms;         ─ T                      join_any                │
    join_any                                 disable fork;  ← kills  │
    disable fork;  ← kills S or T            only S/T (children of P')│
                     AND M (sibling!)      end join                  ┘
                                           M survives: it is a child of
  M, S, T are ALL children of run()       run(), not of the inner P'.
   all in the blast radius.

The isolation idiom: fork begin ... end join

systemverilog
task run();
  fork mon.run(); join_none        // background daemon — must survive

  // Isolation wrapper: ONE child (the begin..end), which becomes the
  // parent of the racing pair. disable fork inside it cannot reach mon.
  fork
    begin
      fork
        main_sequence();           // the work
        #1ms;                      // the timeout
      join_any
      disable fork;                // kills only main_sequence / timeout
    end
  join                              // wait for the wrapper itself
endtask

wait fork and std::process

wait fork blocks the current process until ALL of its spawned children complete — the cleanup companion to join_none. A test that spawned daemons and short-lived workers calls wait fork before reporting, so no result is read while a checker is still mid-flight. Like disable fork, it applies to all children of the current process, so structure your forks accordingly.

For per-thread control, std::process gives each spawned thread a handle: process::self() captured inside the child can be stored, then the parent can kill() exactly one thread, await() its completion, suspend() / resume() it, or query status(). This is the scalpel that disable fork is not — UVM's phase machinery and sequence kill logic are built on it.

systemverilog
class worker_pool;
  process workers[3];               // handles, one per thread

  task start();
    foreach (workers[i]) begin
      automatic int idx = i;
      fork
        begin
          workers[idx] = process::self();   // FIRST statement: register
          forever begin
            #(10 * (idx + 1));
            $display("[%0t] worker %0d tick", $time, idx);
          end
        end
      join_none
    end
  endtask

  task stop_one(int idx);
    if (workers[idx] != null && workers[idx].status() != process::FINISHED)
      workers[idx].kill();          // surgical: siblings unaffected
  endtask

  task drain();
    foreach (workers[i])
      if (workers[i] != null) workers[i].await();   // wait for THIS one
  endtask
endclass

The timeout watchdog pattern

Every robust test wraps its main activity in a watchdog so a stuck handshake fails loudly at a bounded time instead of running until the LSF job is killed. The pattern combines everything from this lesson: isolation wrapper, join_any race, and disable fork.

systemverilog
task automatic run_guarded(time limit);
  bit timed_out;
  fork
    begin                                  // isolation wrapper
      fork
        begin
          stimulus_and_checks();           // the real test body
        end
        begin
          #limit;
          timed_out = 1;
        end
      join_any
      disable fork;                        // safe: only the pair above
    end
  join
  if (timed_out)
    $fatal(1, "[%0t] WATCHDOG: test exceeded %0t", $time, limit);
  else
    $display("[%0t] test finished within budget", $time);
endtask

Interview angle

  • "What exactly does disable fork kill?" — all live children of the calling process, including earlier join_none spawns.

  • "How do you kill one thread without touching others?" — store process::self() handles and call kill() on the target.

  • "Why wrap join_any/disable fork in fork begin..end join?" — to shrink the blast radius to the intended pair.

Key takeaways

  • disable fork kills ALL live children of the current process — wrap races in fork begin..end join to isolate.

  • wait fork is the cleanup barrier for join_none daemons before final checks and reports.

  • std::process handles (self/kill/await/suspend/status) give surgical per-thread control.

  • The watchdog pattern — isolation wrapper + join_any + disable fork — belongs in every top-level test.

Common pitfalls

  • Bare disable fork after a timeout race — silently kills background monitors spawned earlier in the same task.

  • Registering process::self() after a blocking statement — the parent may act on a still-null handle.

  • Calling kill() on a thread holding a semaphore key — the key is never put back; later gets hang.

  • Using disable LABEL on a task enabled from multiple threads — disables every active instance, not just yours.