Repository navigation
Conversation
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
3 open issues
This changes behavior for non-Thrift (or currently unhandled) default objects: previously defaults… · New This example mutates (and freezes)SpecNamespace::NestedDefaults::FIELDSdefaults globally, which… · New Thrift map keys are restricted to scalar types (no containers/structs), so recursively calling… · New
What changed in this PR
This PR fixes Ruby generated struct defaults so nested mutable values (containers, nested structs/unions, strings) are deep-copied per instance, preventing cross-instance sharing and mutation of the default template (including during deserialization when defaults are restored).
Changes:
- Add new spec fixtures and RSpec coverage for nested/default isolation, union defaults, and constructor behavior.
- Implement recursive default copying in Ruby (
Struct.copy_default_value) and use it during initialization and default restoration after reads. - Update the native extension to delegate non-scalar default copying to the Ruby helper for consistent behavior.
| File | Description |
|---|---|
| lib/rb/spec/struct_spec.rb | Adds specs validating deep-copy behavior for nested defaults and read-time default restoration. |
| lib/rb/spec/ThriftSpec.thrift | Introduces new Thrift structs/unions used as fixtures for nested default behaviors. |
| lib/rb/lib/thrift/struct.rb | Replaces shallow dup with recursive copying via Struct.copy_default_value. |
| lib/rb/ext/struct.c | Changes C default restoration to avoid shallow copying and defer deep-copying to Ruby helper. |
Review effort: Lite
| else | ||
| value | ||
| end |
| defaults = SpecNamespace::NestedDefaults::FIELDS | ||
| defaults[7][:default].first.freeze | ||
| defaults[7][:default].freeze | ||
| defaults[5][:default].freeze |
| when Hash | ||
| value.each_with_object({}) do |(key, element), copy| | ||
| copy[copy_default_value(key)] = copy_default_value(element) | ||
| end |
Client: rb Co-Authored-By: OpenAI Codex (gpt-6.1-sol) <codex@openai.com>
55f6dea to
e0f1685
Compare
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
6 open issues
Deep-copying Array/Hash/Set currently re-materializes them as base classes (Array/Hash/Set), which… · New This example mutates class-level generated defaults (by freezing them) and does not restore… · New This example mutates (and freezes)SpecNamespace::NestedDefaults::FIELDSdefaults globally, which… This changes behavior for non-Thrift (or currently unhandled) default objects: previously defaults… For String defaults, using rb_obj_dup works but is more generic than necessary. Using the… · New Thrift map keys are restricted to scalar types (no containers/structs), so recursively calling…
Review effort: Lite
| when Array | ||
| value.map { |element| copy_default_value(element) } | ||
| when Hash | ||
| value.each_with_object({}) do |(key, element), copy| | ||
| copy[copy_default_value(key)] = copy_default_value(element) | ||
| end | ||
| when Set | ||
| value.each_with_object(Set.new) { |element, copy| copy.add(copy_default_value(element)) } |
| defaults = SpecNamespace::NestedDefaults::FIELDS | ||
| defaults[7][:default].first.freeze | ||
| defaults[7][:default].freeze | ||
| defaults[5][:default].freeze |
| VALUE value; | ||
| if (RB_TYPE_P(default_value, T_STRING)) { | ||
| value = rb_obj_dup(default_value); | ||
| } else if (RB_SPECIAL_CONST_P(default_value) || RB_TYPE_P(default_value, T_FLOAT) || RB_TYPE_P(default_value, T_BIGNUM)) { | ||
| value = default_value; | ||
| } else { | ||
| value = rb_funcall(thrift_struct_module, copy_default_value_method_id, 1, default_value); | ||
| } |


Ruby currently duplicates only the outer object when assigning a generated field default. Two independently created structs can therefore share nested lists, maps, sets, or mutable fields of a default struct or union. Changing one instance can also change the template used by future instances. Deserialization repeats the same shallow copying when restoring omitted defaults.
Copy the supported mutable Thrift values recursively, both during initialization and when restoring defaults in Ruby or the native extension. Nested structs and unions are duplicated without rerunning their constructors; immutable scalars are retained. Explicitly supplied arguments keep their existing ownership semantics.
Benchmarks
Ruby 4.0.7 on aarch64 Linux, five warmed process trials per revision, pinned to CPU 0, without YJIT. The baseline is master
7e288b753; the relevant Ruby runtime sources are unchanged atae74fc8e8.Protocol measurements used the repository harness with 125,000 small reads:
Pure-Ruby runs disabled the extension, set
THRIFT_BENCHMARK_SKIP_NATIVE=1, and omitted the accelerated Binary scenario. Native runs verified that Struct's read/write methods were C implementations.Times are milliseconds: median [minimum–maximum].
The ranges overlap: these results do not establish a meaningful speedup. The harness's
OneOfEachfixture has no defaults, so these workloads are a control, not a measurement of deep-copy costs.Separate construction probes measured those costs:
FooinstancesAllocations increased from 300,007 to 375,007 for
Foo, and from 10,001 to 42,001 for the synthetic nested-default chain. Independent mutable defaults cost more than sharing their contents: approximately 1.68 additional microseconds perFooinstance in this environment. The nested-chain result is a synthetic depth probe, not an application throughput estimate.