# Concurrency > Run steps in parallel with step.parallel, step.spawn, and step.join. BountyHub provides **structured concurrency**: branches are declared in the workflow, their lifecycle is tracked durably by the runner, and synchronization is explicit. There are three primitives: - `step.parallel(branches)` — run groups of steps concurrently and wait for all. - `step.spawn(steps)` — start an independent branch without blocking. - `step.join(handles)` — block until one or more spawned branches finish. ## Parallel Branches: `step.parallel` `step.parallel` takes an **outer list of branches**. Each branch is a list of steps that run sequentially; all branches run concurrently. The step completes when every branch reaches a terminal state: ```bhw activity( name = "surface-scan", triggers = [trigger.cron("0 0 * * *")], steps = [ # Subdomain enumeration, port scanning, and directory brute-forcing # are independent — run them at the same time. step.parallel([ [ step.exec(command = ["subfinder", "-d", config.var("target_domain"), "-o", "subs.txt"]), ], [ step.exec(command = ["naabu", "-host", config.var("target_domain"), "-o", "ports.txt"]), ], [ step.exec(command = ["ffuf", "-w", "dirs.txt", "-u", "https://{}/FUZZ".format(config.var("target_domain")), "-o", "ffuf.json"]), ], ]), # Runs only after ALL branches above have finished successfully. step.script(content = "echo surface scan complete"), ], ) ``` Key semantics: - The outer list is the concurrency: three branches above run at the same time. - Each inner list is sequential: `ffuf` waits for the wordlist download within its own branch only. - `parallel([])` is an immediate no-op. - A flat (non-nested) list is rejected — wrap single steps in their own lists. ### Failure behavior If a branch fails, `step.parallel` fails — but running siblings are **not** cancelled. They finish naturally; the join simply reports failure. Use `allow_failure = True` on steps inside branches so a noisy optional phase cannot fail the whole scan. `cond` and `allow_failure` are also accepted on `step.parallel` itself to gate or soften the entire barrier. Per-branch `name`/`timeout` cannot be set on `step.parallel`; set `timeout` on the steps inside each branch. ```bhw step.parallel([ [step.exec(command = ["nuclei", "-l", "live.txt", "-severity", "critical", "-o", "crit.txt"])], # Passive checks may fail (rate limits, dead endpoints) without killing the run. [step.exec(command = ["passive-tool", "-in", "live.txt"], allow_failure = True)], ]) ``` ## Spawn And Join: `step.spawn` / `step.join` When you need to keep working while a long-running phase executes in the background, use `step.spawn`. It returns a **handle** — an execution reference, never data. Bind the handle to a variable, keep going with other work, then synchronize with `step.join`: ```bhw # Start the slow full-port scan in the background. ports = step.spawn([ step.exec( command = ["naabu", "-host", config.var("target_domain"), "-p", "-", "-o", "all-ports.txt"], timeout = duration.hours(6), ), ]) activity( name = "surface-scan", triggers = [trigger.cron("0 0 * * *")], steps = [ # Continue immediately with the web-focused scan... step.exec(command = ["httpx", "-l", "subs.txt", "-o", "live.txt"]), # ...and wait for the background port scan before finishing. step.join([ports]), ], ) ``` `step.join(handles)` blocks until every joined branch reaches a terminal state. It succeeds only if **all** joined branches succeed; if one fails, the join fails without cancelling any still-running siblings. An empty handle list is rejected. `step.spawn` accepts `name` and `timeout`. Its `allow_failure` parameter is accepted but ignored: branch failure always propagates to `step.join`. Because handles are synchronization references only, spawn/join never merge data between branches. If branches need to exchange files, do it through the workspace or artifacts. ## Choosing a Primitive
| Need | Use |
|---|---|
| Run independent scans side by side |
step.parallel([...])
|
| Fire off a slow job and keep going |
handle = step.spawn([...])
|
| Wait for spawned jobs before ending |
step.join([handle])
|
| Fan out over many targets/items |
step.for_each
|