Skip to content

Optional<Struct> reads garbage enum tag bytes on iOS Release via Swift Cxx-interop bridge #1319

Description

@notsuhas

What happened?

Optional<Struct> round-trips through the Swift Cxx-interop bridge corrupt enum tag bytes when the wrapping struct is wide and contains multiple distinct string-union enum optional fields. The C++-side JS converter then sees a non-discriminator integer in the enum slot and throws e.g. Cannot convert Tier to JS - invalid value: 83886081.

The garbage value differs per call (observed 83886081 and 2003789939 in separate runs), and the bug surfaces only under optimization (Swift -O / Clang -Os/-O3). iOS Debug, Android (no Cxx-interop in play), and narrower struct shapes all round-trip cleanly.

Surface conditions

Build Result
iOS Debug ✅ passes
iOS Release ❌ tryOptionalEnumStruct(...) equals fails — Cannot convert <Enum> to JS - invalid value: <garbage int>
Android ✅ passes on both TestObjectCpp and TestObjectSwiftKotlin (no Cxx-interop)

Bisection (three repro tests on TestObject, increasing in width)

Repro Shape iOS Release
tryOptionalStruct(value?: OptionalWrapper) 2 primitive optionals, no enums ✅
tryOptionalNestedStruct(value?: OptionalNested…) 3 fields incl. nested struct with 2× same Side enum ✅
tryOptionalEnumStruct(value?: OptionalEnum…) 14 primitive optionals + 2 distinct enums + nested struct with 2 more enums ❌

Together these bracket the bug: small + few enums = works, wide + multiple distinct string-union enum types under -O = corrupted enum tag. Garbage values being non-deterministic across calls (and different from a stable wrong-pointer value) point at uninitialized padding bytes or an optimizer-revealed layout mismatch in Swift's Optional<Struct> projection across the Cxx-interop bridge — not a stable wrong-pointer or logic bug.

Reproduceable Code

Repro branch (on top of mrousavy/nitro@main): https://github.com/notsuhas/nitro/tree/repro/optional-struct-bridge

It adds three runtime tests to react-native-nitro-test's TestObject. Spec (packages/react-native-nitro-test/src/specs/TestObject.nitro.ts):

export type Side = 'left' | 'right'
export interface OptionalNestedInner { start?: Side; end?: Side }
export interface OptionalNestedWrapper {
  count?: number; enabled?: boolean; inner?: OptionalNestedInner
}

export type Stage = 'idle' | 'warmup' | 'active' | 'cool'
export type Tier = 'free' | 'pro' | 'enterprise'
export type Region = 'us' | 'eu' | 'apac' | 'global'
export type Tone = 'cool' | 'warm' | 'neutral'
export interface OptionalEnumInner { stage?: Stage; tone?: Tone }
export interface OptionalEnumWrapper {
  count?: number; weight?: number; ttl?: number; jitter?: number;
  retries?: number; delayMs?: number; timeoutMs?: number; ratio?: number;
  threshold?: number;
  enabled?: boolean; active?: boolean; shouldBuffer?: boolean;
  shouldRetry?: boolean; verbose?: boolean;
  tier?: Tier; region?: Region;
  inner?: OptionalEnumInner
}

interface SharedTestObjectProps {
  // ...
  tryOptionalStruct(value?: OptionalWrapper): OptionalWrapper | undefined
  tryOptionalNestedStruct(value?: OptionalNestedWrapper): OptionalNestedWrapper | undefined
  tryOptionalEnumStruct(value?: OptionalEnumWrapper): OptionalEnumWrapper | undefined
}

Native impls are identity bounces (return value) on Cpp / Swift / Kotlin. JS-side fixtures + tests in example/src/getTests.ts. Build the example in Release on iOS, open the test runner, run the TestObjectSwiftKotlin suite — tryOptionalEnumStruct(...) equals fails as described.

Relevant log output

❌ Test "tryOptionalEnumStruct(...) equals" failed!
Error: Expected test to not throw any errors, but an error was thrown!
Error: Error: TestObjectSwiftKotlin.tryOptionalEnumStruct(...):
Cannot convert Tier to JS - invalid value: 83886081!

(Tier / Region / Stage / Tone rotate as the offending field across runs; the integer value differs per call.)

Suspected root cause + candidate fix

The Cxx-interop wrapper for Optional<Struct> uses Swift's .value direct-access projection on std::optional<T> for both parameter unwrapping and field reads. Generated example:

// HybridTestObjectSwiftKotlinSpec_cxx.swift
public final func tryOptionalEnumStruct(value: bridge.std__optional_OptionalEnumWrapper_) -> ... {
  let __result = try self.__implementation.tryOptionalEnumStruct(value: value.value)
  //                                                                    ^^^^^^^^^^^^
  // direct .value projection — reads garbage enum tag bytes under -O
}

The pre-existing TODO at packages/nitrogen/src/syntax/swift/SwiftCxxBridgedType.ts already routes optional booleans/numbers through bridge.has_value_* / bridge.get_* shims to dodge swiftlang/swift#84848, with the comment "…and who knows what else?". This appears to be one of those what else cases.

I have a local candidate fix that extends the same workaround to also cover:

  • kind === 'struct'
  • kind === 'enum' && jsType === 'union' (string-union enums)

The bridge has_value_* / get_* shims are already emitted by the C++ bridge generator for both struct and enum optionals, so no bridge-side work is needed.

Verified on-device, iOS Release, after that patch: all 6 tryOptional*Struct cases pass; 244/245 of the existing test suite passes (the remaining 1 is callCallbackThatReturnsPromiseVoid timing out after repeated runs — unrelated, appears to be a pre-existing test-harness state-pollution flake reproducible without my changes).

Happy to share the patch / open a PR if that's the direction you'd want. Flagging here first since it's a codegen change with a non-obvious blast radius and you may want to take a different approach (e.g. tighten the workaround further, change layout assumptions, file a Swift-team bug, etc.).

Device

iPhone, iOS Release build (physical device). Reproducible across runs.

Nitro Modules Version

0.35.6

Nitrogen Version

0.35.6

Can you reproduce this issue in the Nitro Example app here?

Yes, I can reproduce the same issue in the Example app here

Additional information

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