4.4.4 Persistent Workers for Rule Authors

recommended

Persistent workers are worth adding only when the rule and the underlying tool can uphold a stronger contract than a normal one-shot action. Bazel can keep a worker process alive and send it many requests, but the rule author must define what stays stable at process startup, what varies per action, how Bazel talks to the process, and how correctness is preserved when state survives between requests.1 This builds on the execution-strategy vocabulary from 2.5 Execution Strategies. This article focuses on the rule-author surface, while pool sizing and performance operations continue in 6.4 Execution Modes and Persistent Workers.

Reuse the process, but keep every action isolated
Startup arguments choose a worker process. The final @flagfile becomes data for each request.
RULE ACTION
Declares worker support
supports-workers: 1
requires-worker-protocol: proto
stable mnemonic · startup args · final @flagfile
BAZEL
Starts or reuses a matching process
Worker key includes mnemonic and startup arguments.
many actionsone warm process
WORKER PROCESS
Handles requests repeatedly
Reads stdin, writes protocol messages to stdout, and creates declared outputs.
diagnosticsWorkResponse.output
ONE REQUEST / RESPONSE PAIR PER ACTION
WorkRequest flagfile arguments + input digests + request_id
WorkResponse same request_id + exit code + output text
Singleplex uses request_id 0 and processes one request at a time.
MULTIPLEX IS A STRONGER CONTRACT
One process may handle concurrent requests
supports-multiplex-workers wins nonzero request_id thread-safe state atomic responses sandbox_dir when enabled
Warm state is an optimization. Correct outputs, clean protocol I/O, and request isolation remain the contract.

Start with the Tool, Not the Flag

A worker is either the tool itself or a wrapper around the tool. It must accept --persistent_worker, then read WorkRequest messages from stdin and write WorkResponse messages to stdout. When the flag is absent, it should still be able to run as a normal one-shot tool.2 That fallback matters because Bazel may invoke the same executable with and without worker mode in one build, depending on strategy selection and action support.3

Good worker candidates have repeated actions with high startup cost or useful cross-action caches: compilers, transpilers, code generators, and analysis tools. The official docs call out startup overhead, JIT warmup, and cached ASTs as the common wins.4 In practical terms, workers help when the compiler is architected to reset and reuse state safely. They are not a magic adapter for arbitrary command-line tools.5

The strongest design question is therefore: can the tool behave as if every request were isolated? If the tool leaves global state behind, uses process-wide temp files, or writes diagnostics to the wrong stream, worker mode can turn a harmless one-shot bug into a sequence-dependent build failure.6

The Rule-Side Contract

The Starlark rule must expose a stable executable, usually through a private executable attr in the execution configuration, so the worker runs on the execution platform rather than the target platform.7 It must also register actions with a stable mnemonic, because users route worker strategy by mnemonic:

_my_rule = rule(
    implementation = _impl,
    attrs = {
        "_worker": attr.label(
            default = Label("//tools/my_compiler:worker"),
            executable = True,
            cfg = "exec",
        ),
    },
)

The action must declare worker support through execution_requirements. supports-workers opts the action into the worker strategy, while requires-worker-protocol selects JSON or protobuf. Protobuf is the default when the protocol key is omitted.8 That protocol selection is part of the action's execution contract: when remote execution is involved, Bazel forwards it as the persistentWorkerProtocol platform property so the remote side sees the same protocol expectation.9

args_file = ctx.actions.declare_file(ctx.label.name + ".worker.args")
ctx.actions.write(
    output = args_file,
    content = "\n".join(per_request_args),
)

ctx.actions.run(
    executable = ctx.executable._worker,
    inputs = depset(direct = [args_file], transitive = [src_inputs]),
    outputs = [out],
    arguments = [
        "--max_mem=4G",          # startup argument
        "@%s" % args_file.path,  # per-request arguments
    ],
    mnemonic = "MyCompiler",
    execution_requirements = {
        "supports-workers": "1",
        "requires-worker-protocol": "proto",
    },
)

The arguments list has two roles. Everything before the final @flagfile is a startup argument for the worker process. The flagfile content becomes the per-request argument list in each WorkRequest.10 Keep startup arguments low-cardinality: Bazel includes the mnemonic and startup flags in the worker key, so letting every target choose different startup options can multiply live worker processes and memory use.11

The Protocol Is Part of the API

A WorkRequest carries request arguments, input path/digest pairs, and a request id. A WorkResponse carries the same request id, an exit code, and an output message.12 For singleplex workers, the request id is 0. Multiplex workers use nonzero ids to match concurrent responses to requests.13

stdout is reserved for protocol messages. If the worker or a wrapped compiler writes ordinary diagnostics there, Bazel can no longer reliably parse responses. Capture tool output and put user-facing text in the WorkResponse.output field. Use the worker process stderr only for worker logs, knowing that those logs are attached to the worker process rather than one specific action.14

JSON and protobuf carry the same logical fields, but JSON uses camelCase names such as requestId. Protobuf uses snake_case fields such as request_id. JSON workers must tolerate unknown fields and protobuf defaults.15 That detail sounds small until you maintain wrappers in multiple languages: even reading protobuf messages from an unbounded stdin stream requires library-level care around length-delimited messages.16

Correctness Before Warmth

Worker performance comes from keeping process state warm. Worker correctness comes from proving that warm state cannot change outputs for the wrong request. Bazel sends input digests in the request so the worker or wrapper can validate cached compiler state against the inputs Bazel knows about, but this is cooperative: it does not by itself prevent hidden in-memory state from leaking between requests.17

Use --worker_sandboxing while developing and testing a worker-backed rule. It gives each request a sandboxed input view, which catches many undeclared input problems, but it is still weaker than a fresh one-shot sandbox because the process can retain internal state.18 A real GHC worker port surfaced test failures when the compiler had host-side access it should not have had. The practical fix was to always test worker mode with sandboxing enabled.19

Cancellation is another correctness boundary. Workers can opt into cancellation with supports-worker-cancellation: 1 plus --experimental_worker_cancellation. Even when a request is cancelled, each non-cancel request must be answered exactly once, and after sending the response the worker must not touch files in its working directory because Bazel may clean them up.20

Multiplex Workers Raise the Bar

Multiplex workers are a different promise, not just a faster checkbox. A multiplex worker lets one process handle several concurrent requests, which can reduce memory pressure and share one cache across requests for tools such as Java or Scala compilers.21 To opt in, the action declares supports-multiplex-workers. This takes precedence over supports-workers when both are present.22

The worker implementation then has to become thread-safe. It must preserve request_id, write responses atomically so messages do not interleave, and handle user-visible output without mixing text from parallel requests.23 If multiplex sandboxing is needed, the worker must read sandbox_dir from each WorkRequest, translate all input and output paths through that directory, and declare supports-multiplex-sandboxing on the action. Bazel still needs --experimental_worker_multiplex_sandboxing or a dynamic-execution path that requires multiplex sandboxing before that support is used.24

Treat multiplexing as an extra capability after the singleplex contract is proven. A non-thread-safe worker with shared temp files, global compiler state, or output written straight to stderr is not ready for multiplex mode.25

The runnable mini-ruleset connects both halves of the contract: the worker-backed action and its persistent worker adapter.

key takeaway

Worker support is a public execution contract: stable mnemonic, executable in exec config, flagfile-shaped requests, explicit execution_requirements, a protocol-clean worker process, and tests that prove state does not leak between requests. Add it when startup or cross-action cache reuse is real. Keep one-shot execution working so users can still fall back when worker mode is wrong for a build.

extra

The adapter's protocol binding also illustrates the distribution boundary from 4.11.2 Toolchainization. The published worker uses checked-in official protoc output and a release runtime wheel. A maintainer-only target regenerates it with Protobuf 34's official prebuilt compiler. The fail-closed check rejects Protobuf C++ actions before regeneration. This packaging choice is independent of whether GlyphCompile executes one-shot or as a persistent worker.26

Check your understanding · 3 questions

1.What must be true before a custom rule should advertise worker support?

Select one answer

2.Which action-side details are part of the persistent-worker contract?

Select all that apply

3.True or false: worker protocol and sandboxing behavior.

Choose True or False for each sentence

A worker's stdout is reserved for WorkResponse protocol messages.
Worker sandboxing prevents all possible state leaks between requests.
Multiplex workers must preserve request IDs so Bazel can match responses to requests.
Declaring supports-multiplex-sandboxing alone guarantees multiplex sandboxing is active.
requires-worker-protocol only affects local worker startup and is invisible to remote execution.
0 of 3 answered

Footnotes

  1. Creating Persistent Workers — worker implementation has two parts: the worker process and the rule that uses it.

  2. Creating Persistent Workers — worker requirements: --persistent_worker, WorkRequest on stdin, WorkResponse on stdout.

  3. Persistent Workers — the same worker may be called with and without --persistent_worker in one build.

  4. Persistent Workers — execution-strategy benefits: startup overhead, JIT compilation, and cached ASTs.

  5. How to Create a Persistent Worker for Bazel — compiler architecture and safe reset requirements for worker suitability.

  6. Bazel persistent workers — sandboxing and long-lived-process risks: in-memory state, shared temp areas, and cleanup assumptions.

  7. Creating Persistent Workers — worker attr uses executable = True and cfg = "exec".

  8. Creating Persistent Workerssupports-workers, supports-multiplex-workers, and requires-worker-protocol action requirements.

  9. Persistent Workersrequires-worker-protocol also forwards the selected protocol to remote execution via the persistentWorkerProtocol platform property.

  10. Creating Persistent Workers — startup arguments precede the final @flagfile, whose contents become per-request arguments.

  11. Persistent Workers — WorkerKey composition from mnemonic and startup flags, with memory implications.

  12. Creating Persistent WorkersWorkRequest and WorkResponse field shape.

  13. Creating Persistent Workersrequest_id 0 for singleplex and nonzero ids for multiplex requests.

  14. Creating Persistent Workers — stdout safety and response output vs worker stderr logs.

  15. Creating Persistent Workers — JSON/protobuf naming and compatibility notes.

  16. Bazel's Persistent Worker Mode for GHC: An Industrial Internship — protobuf streaming and stdin/stdout integration challenges in the GHC worker.

  17. Persistent Workers — input digests for worker cache validation and cooperative correctness.

  18. Persistent Workers--worker_sandboxing behavior and limits versus pure sandboxing.

  19. Bazel's Persistent Worker Mode for GHC: An Industrial Internship — rules_haskell worker tests and the need for --worker_sandboxing.

  20. Creating Persistent Workers — cancellation protocol and post-response file access rule.

  21. Multiplex Workers (Experimental Feature) — multiplex workers share one process and cache across parallel requests.

  22. Multiplex Workers (Experimental Feature)supports-multiplex-workers enablement and precedence.

  23. Multiplex Workers (Experimental Feature) — thread-safe request handling, atomic responses, and output handling.

  24. Multiplex Workers (Experimental Feature)sandbox_dir and supports-multiplex-sandboxing contract.

  25. Bazel persistent workers — multiplex complexity around thread safety, shared temp files, and output directories.

  26. Pre-built protoc binaries for Bazel — Protobuf 34 defaults to the official prebuilt compiler after Protobuf 33.4 introduced the opt-in toolchain.