Skip to content

state: defer full generic closure and method-value migration support #32

Description

@MeteorsLiu

Status

Full support for migrating native generic closures and generic method values is deferred. This issue records the confirmed gaps, practical workarounds, and the difficulty of completing the implementation. The partial generic-support experiment must not be treated as support for arbitrary generic environments.

The missing information

State needs the concrete types of captured values to recursively encode their objects and preserve references. Finding the closure PC and its ELF allocation layout is not sufficient when the layout contains shared Go shape types.

For example:

type Node struct {
    N    int
    Next *Node
}

type Other struct {
    Text string
    Link *Node
}

func capture[T, U any](a T, b U) func() any {
    return func() any {
        return []any{b, a}
    }
}

With T = *Node and U = *Other, the examined layout and type entries are:

funcval:
    F  = closure PC
    X0 = b, represented as go.shape.*uint8
    X1 = a, represented as go.shape.*uint8
    X2 = dictionary pointer

dictionary type entries:
    slot 0 -> *Node
    slot 1 -> *Other
    additional entries omitted

The dictionary contains both concrete types, but the shape descriptor does not say that X0 belongs to U and X1 belongs to T. Using []any{a, b} instead changes the capture order without changing those two dictionary entries. Capture order, parameter order, and dictionary order cannot be assumed to coincide.

An actual diagnostic from the state test package was:

ambiguous dictionary types for go.shape.*uint8: *state.nativeGenericNode and *state.nativeGenericOther

Copying the PC and environment bytes verbatim does not resolve this: captured host pointers still need relocation and their target objects still need correct traversal.

Investigation results

The investigation used Go 1.26.6 on Linux arm64 and amd64, with normal optimization and ELF symbols retained. Both architectures produced the same layouts and resolution outcomes for 24 diagnostic cases. All 24 raw environment layouts were recovered; 8 concrete environment views resolved and 16 reported resolution errors. These were metadata diagnostics, not successful migration round trips or a representative failure-rate measurement.

Capture category Confirmed behavior
Direct T/U captures, reversed order, three type parameters, and typed nil values Multiple concrete candidates can share the same shape.
Captures by reference and objects created inside the generic function Extra pointer indirection does not restore the missing type-parameter identity.
Anonymous/named structures and recursive generic types A unique whole-type candidate can resolve, but two structures with T/U exchanged or two same-shape recursive instantiations remain ambiguous.
Arrays, slices, maps, channels, and captured functions The ambiguity can occur inside their element, value, or signature types as well. Some instances have additional metadata, such as a channel's element type; this is not a universal solution.
Nested closures Outer and inner environments can both contain ambiguous shapes.
Values already boxed into an interface or reflect.Value Their own dynamic type information provides an independent type source.
Concrete bound methods and method expressions The examined bound method retained a concrete receiver; the method expression had no captured receiver.
A method value obtained through a type parameter The compiler-generated wrapper captured a method function and receiver without a direct dictionary-pointer field. The captured function value pointed into a dictionary method slot, providing another ownership relation. The current generic environment resolver did not handle this case.

Allocation metadata is not a universal escape hatch

Allocation tracing confirmed that some generic locals are allocated with shape descriptors in the first place. For a local struct { First T; Second U } instantiated with *Node and *Other, the allocation event recorded:

struct { First go.shape.*uint8; Second go.shape.*uint8 }

For two variables captured by reference, the actual variable types were *Node and *Other, but both allocation events recorded go.shape.*uint8. This was reproduced on both architectures. Even recording address-to-allocation-type associations from the beginning would therefore not guarantee recovery of concrete capture types.

GC metadata can establish allocation boundaries and pointer positions, but equal sizes, offsets, and pointer bitmaps do not establish Go type identity. An already discovered, accurately typed reference can help identify a shared object, but closure-only graphs do not necessarily contain such a reference.

Compiler metadata and DWARF

The compiler retains variable identity, generic type relationships, capture mode, and final field offsets during compilation. ir.NewClosureVar, noder.varDictIndex, and typecheck.ClosureStructIter contain relevant pieces. However, the runtime closure descriptor produced by ClosureType in Go 1.26.6 does not preserve the complete capture-field-to-concrete-type relationship.

DWARF does not provide a guaranteed replacement. Earlier probes found capture offsets without dictionary indices even with -N -l; ordinary optimization also omitted some dictionary-variable information. This is not simply a matter of retaining the DWARF section. The experiments did not establish that every closure subprogram DIE disappears under optimization, and disabling optimization does not by itself solve the mapping problem. Compiler export data also is not a complete, universally available runtime side table.

Workaround: sync.Once.Do plus a concrete callback

Where sync.OnceValue(fn) introduces an unsupported generic environment, write the once-only operation with concrete result storage in the caller:

// fn has a concrete type here, for example func() *Config.
var once sync.Once
var result *Config

get := func() *Config {
    once.Do(func() {
        result = fn()
    })
    return result
}

This replaces the generic OnceValue wrapper with an ordinary closure over once, result, and fn. Keep this code at a concrete call site: moving it into another helper parameterized by T can reintroduce the same problem. The original fn and everything it captures must still be supported by state; this workaround removes only the generic once wrapper. For sync.OnceValues, the same approach can use two concrete result variables.

Preserving sync.Once completion across transfers was merged in #31. This workaround does not require a new sandbox API or a sync.OnceValue-specific serializer.

The short example is not fully equivalent on panic: OnceValue repeats the same panic on every call, while Once.Do marks the operation complete after the first panic. Callers that require OnceValue's panic behavior must also retain and replay the panic state in the concrete closure. Avoid describing the simplified example as a drop-in replacement for all semantics.

Why full support is deferred

This is more than extending a list of supported generic types. A complete implementation must establish the relationship between each capture's storage and its concrete type, including references, nested type expressions, recursive objects, and compiler-generated method wrappers. Neither choosing the first matching dictionary entry nor matching memory layout alone proves that relationship.

Revisiting ELF instruction analysis is a possible research direction, reusing the existing native layout machinery. It would need to trace value origins and stores into environment fields, and sometimes their use with dictionary entries, rather than stopping at allocation-type discovery. It has not been shown to resolve every case: some operations do not require concrete type information and therefore need not expose a direct field-to-dictionary association in the code.

Preserving additional compiler-side metadata is another possible direction, but it changes the build/toolchain requirements. Neither direction is selected for implementation in this issue. Defer the work and use concrete caller-side alternatives where practical.

The partial implementation investigated here is e9fb262; the expanded diagnostic probes were temporary and are not included in that commit.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions