The Name That Could Not Be Shadowed: A C++ Scoping Lesson in the Phase 12 Split API Fix

In the middle of an intensive optimization session for the cuzk SNARK proving engine — a high-performance Groth16 proof generation pipeline for Filecoin's Proof-of-Replication (PoRep) — the assistant encountered a seemingly trivial but instructive compile error. The subject message, <msg id=3023>, is just two sentences long:

Right — provers was already declared as a function parameter. I can't shadow it with a const auto& in the same scope. I need to use a different name: [edit] /home/theuser/curio/extern/supraseal-c2/cuda/groth16_cuda.cu Edit applied successfully.

This brief moment of recognition belies a deep and consequential debugging story. The message represents the collision between an elegant fix for a critical concurrency bug and an immutable rule of the C++ language: function parameters and local variables inhabit the same scope, and one cannot shadow the other. Understanding why this message was written — and what it cost to arrive at this point — requires tracing the thread of a use-after-free bug that threatened the correctness of the entire Phase 12 split GPU proving API.

The Use-After-Free That Was Hiding in Plain Sight

The Phase 12 optimization was designed to offload the b_g2_msm computation (a multi-scalar multiplication on the G2 curve) from the GPU worker's critical path. In the original synchronous design, the GPU worker held the lock for the entire proof generation, including the ~1.7 seconds spent on b_g2_msm. Phase 12 split this into a start function that released the GPU lock early and a finalize function that completed the proof later, allowing the GPU worker to begin processing the next partition while b_g2_msm ran in a background thread.

But this split introduced a subtle memory safety issue. The C++ function generate_groth16_proofs_start_c received a provers parameter — a pointer to an array of Assignment<fr_t> structs allocated by the Rust side. This array contained raw pointers into Rust-owned memory (the inp_assignment_data, aux_assignment_data, and density bit-vectors). The background thread (prep_msm_thread) captured this array by reference using [&, num_circuits] — a C++ lambda capture that grabs all automatic variables by reference.

In the old synchronous code, this was safe: the function did not return until the thread was joined. In the new split API, generate_groth16_proofs_start_c returned immediately, the Rust start_groth16_proof function dropped its local Vec<Assignment>, and the background thread was left reading from freed memory. The provers pointer itself lived on the C++ function's stack — after the function returned, that stack frame was reclaimed, and the thread's reference to the parameter variable was dangling.

The fix was conceptually straightforward: copy the provers array into the heap-allocated groth16_pending_proof struct (pp->provers_owned), so the background thread always accesses stable memory that outlives the function call. The assistant added a std::vector<Assignment<fr_t>> provers_owned field to the struct and populated it with pp->provers_owned.assign(provers, provers + num_circuits).

The Shadowing Strategy and Its Failure

To minimize code changes, the assistant attempted a clever trick: create a local reference alias with the same name as the function parameter, effectively shadowing it. The idea was that all subsequent code — both in the main function body and inside the lambda captures — would transparently use pp->provers_owned through the reference, without needing to rename every usage of provers[c] throughout the 800-line function.

The edit applied in <msg id=3020> added:

const auto& provers = pp->provers_owned;

immediately after the copy, intending this reference to shadow the function parameter const Assignment<fr_t> provers[]. The assistant reasoned in <msg id=3021> about whether lambda captures would resolve correctly, concluding that C++14's reference semantics would make the lambda see pp->provers_owned through the reference.

But C++ has a firm rule: a function parameter name cannot be redeclared as a local variable in the same function scope. The C++ standard ([[basic.scope.block]]) specifies that a name declared in function scope (including parameters) cannot be hidden by a declaration in the same block. The compiler error was unambiguous:

error: "provers" has already been declared in the current scope

The Thinking Process Revealed

The subject message shows the assistant's immediate recognition of its mistake. The word "Right —" signals the moment of realization: the compile error from <msg id=3022> made the C++ scoping rule immediately obvious in retrospect. The assistant had assumed that a const auto& declaration in the function body would shadow the parameter, as it would in many other languages or with other declaration types. But C++ treats function parameters as belonging to the outermost block scope of the function, making shadowing impossible.

This assumption was reasonable but incorrect. In C, function parameters can be shadowed by local variables (they are in different scopes — parameters are in the function's prototype scope, locals in block scope). But in C++, the parameter name is in the same declarative region as the function body's block, and redeclaration is illegal. The assistant, working extensively across Rust, C++, and CUDA, momentarily conflated the two languages' rules.

The Broader Significance

This tiny message — barely a dozen words of analysis — sits at a critical juncture in the Phase 12 implementation. It forced the assistant to abandon the elegant shadowing approach and instead rename the local reference. The subsequent messages show the assistant pivoting to a different strategy: keeping the function parameter provers as-is for the safe (pre-return) code paths, and explicitly using pp->provers_owned[c] only inside the background thread lambda where the dangling reference would be a problem.

The message also reveals something important about the assistant's debugging methodology. Rather than fighting the compiler or attempting a workaround, the assistant immediately accepted the language constraint and adapted. The edit was applied "successfully" — the assistant moved on to the next iteration of the fix without hesitation.

For anyone reading this session, the message serves as a reminder that even experienced developers working across multiple languages can be tripped by subtle differences in scoping rules. The C++ rule against shadowing function parameters exists to prevent confusion and bugs, but it can surprise developers coming from C, Rust, or other languages where such shadowing is permitted. The fix — using a differently named reference — is trivial in retrospect, but the journey to discovering it required understanding a use-after-free bug, the Phase 12 split API architecture, C++ lambda capture semantics, and the precise scope rules of function parameters.