Preserve unknown singular enum values in unknown fields in Swift - #3708
Conversation
When decoding with the .returnNil strategy, an unrecognized value in a singular (or oneof) enum field was read and discarded, so reencoding the message silently dropped the field. Repeated and map enum fields already preserve unrecognized values in unknown fields, as does generated Kotlin/Java code via EnumConstantNotFoundException, and proto2 semantics call for unknown enum values to be treated like unknown fields. Route singular unknown enum values into the current message frame's unknown fields, mirroring the repeated-field path, so they survive a decode/reencode round trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 One behavior change worth calling out explicitly for reviewers, since it isn't covered by the "Known pre-existing edge" section: After this PR, a stale client that explicitly edits a singular enum field decoded with an unrecognized value will have that edit overridden on the wire. Example ( Before this PR the same flow produced This is exactly what Wire Kotlin/Java already do today. Generated Kotlin catches Recommendation: merge with this added to the behavior-change note and pinned by a test ( Separately, a small nit in the "Known pre-existing edge" paragraph: the problematic order is |
A stale client that edits the unknown-valued field itself reencodes the known value followed by the preserved unknown occurrence, so last-wins readers keep the unknown until the client updates. This matches generated Kotlin/Java, GPB proto2, and the existing map/repeated enum handling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 (posted by Logan's agent) Great catch on all three — adopted:
On the follow-up: agreed the generator-level clear-on-mutate ( |
Under
ProtoDecoder's.returnNilstrategy, an unrecognized value in a singular (or oneof) enum field is read and discarded: it never reachesunknownFields, so reencoding the message silently drops the field. This diverges from:ProtoReader.decode(into:));EnumConstantNotFoundExceptionand callsaddUnknownField(...)for singular fields (pinned byUnknownFieldsTest, which asserts the raw varint survives inunknownFields);.returnNildoc comment itself, which already described unknown values as "added to a collection for the same tag in unknown fields" — previously true only for collections.The practical impact is in fetch → decode → mutate → reencode → full-object update flows: when a server starts sending a new enum value, a stale client that edits any other field of the object silently clears the enum field on the way back up.
Change
Route the unrecognized raw value into the current message frame's unknown fields in the singular enum decode path, mirroring the existing repeated-field path (including its
currentTagguard). Runtime-only; no generator or generated-code changes.Tests
testDecodeUnknownEnumNilStrategynow asserts the preserved bytes;testDecodeUnknownEnumInOneOfNilStrategynow expects the value inunknownFields).RoundTripTestscovering a singular/oneof unknown enum (proto2 shape) and a proto3 field (interaction with the zero-value backfill), both asserting the reencoded bytes are identical to the input. A further test pins the edit-shadowing semantics described below (20 05decoded, field set to a known value, reencode asserted as20 01 20 05).swift test: full suite passing locally; all CI jobs green.Behavior change note
Messages decoded under
.returnNilnow carryunknownFieldsentries where they previously had none, which is observable throughEquatable/Hashableand reencoded bytes. Suggested CHANGELOG entry (under### Swift):The flip side of preservation, also worth stating: a stale client that edits the unknown-valued enum field itself now reencodes
[known value][preserved unknown], and last-wins readers keep the unknown — the edit is shadowed until the client learns the new value (previously the drop made the edit win). This matches what generated Kotlin/Java, GPB proto2, and Wire Swift's existing map/repeated enum handling already do, and is pinned bytestEditedSingularEnumFieldReemitsPreservedUnknownValue. The generator-level clear-on-mutate improvement that would remove the shadow (for singular and map/repeated fields) is tracked in #3710.Known pre-existing edge (disclosed, not addressed here)
If the same singular enum tag occurs twice on the wire as
[known, unknown], generated Swift assigns each decode result unconditionally, so the later unknown occurrence overwrites the previously decoded known value withnil(generated Kotlin'scatchskips the assignment; protobuf runtimes keep the recognized value). That generator shape predates this PR; preservation makes it observable across multiple decode/reencode hops. A generator change (skip assignment when the decode returnsnil) is noted as a ride-along in #3710.The Codable path's
JSONDecoder.EnumDecodingStrategy.returnNilis a distinct type and is unchanged.