Skip to content

fix: correctness bugs across engine, generator, CRDT, and conditions#45

Merged
brunoga merged 13 commits into
mainfrom
fix/correctness-bugs
Jun 1, 2026
Merged

fix: correctness bugs across engine, generator, CRDT, and conditions#45
brunoga merged 13 commits into
mainfrom
fix/correctness-bugs

Conversation

@brunoga

@brunoga brunoga commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Summary

Twelve correctness/robustness fixes from the recent code review, plus one doc-only follow-up. Each commit is self-contained and pairs the change with a test.

Bug fixes

  • engine: OpCopy on reference types (slices, maps, pointers) now deep-copies the source so the destination owns its storage. Previously the two fields aliased after Apply.
  • selector: MapKey RFC 6901-escapes the key (/~1, ~~0) so keys containing pointer specials navigate correctly.
  • patch: Reverse drops OpLog operations (they have no state effect; the old code emitted a zero-value Operation that defaulted to OpAdd).
  • patch: ParseJSONPatch only lifts the leading {op:test, path:/, if:...} into Guard; a later same-shape entry no longer silently clobbers it.
  • deep-gen: comma-ok the root-level strict-check op.Old.(T) assertion so a strict OpReplace with a wrong-typed Old returns an error instead of panicking. All in-tree examples regenerated.
  • engine: ApplyOpReflectionValue now nil-checks the logger, matching the pointer variant.
  • hlc: Clock.SetLatest rehydrates the clock under the mutex; CRDT.UnmarshalJSON routes through it. Documented Latest as serialisation-only.
  • hlc: Clock.Reserve panics on negative n or int32 overflow instead of silently wrapping Logical and breaking causal ordering.
  • condition: Not with an empty Sub returns an explicit error instead of falling through to path resolution.
  • crdt: Set.Len no longer allocates the full Items() slice just to take its length.
  • engine (test gap): TestEngineFailures now asserts that OpMove/OpCopy from a non-existent source return an error.

Refactor / wire-format break ⚠️

  • operation: split Operation.From (source path for OpMove/OpCopy, json \"f\") from Operation.Old (prior value across all kinds). Old was overloaded for move/copy as the source path, which prevented Reverse from restoring the displaced value when a copy/move had overwritten an existing destination.

    This is a JSON wire-format change. Patches serialised by v5.2.0 and earlier with the previous encoding (source path under \"o\") will not round-trip cleanly. Generated *_deep.go files are unaffected — they never read those fields for Move/Copy ops, which fall through to the reflection path. Landing under v5 as this was completely broken.

Docs

  • crdt: documented why Set.Add advances the HLC twice. Investigation showed neither order produces a single-tick path because CRDT.Edit ticks unconditionally for non-empty patches.

Test plan

  • go build ./..., go vet ./..., staticcheck ./...
  • go test ./...
  • go test -race ./... (covers the new hlc.Clock.SetLatest concurrency test)
  • Each fix verified by failing the new test on the prior commit before applying the fix.

brunoga added 13 commits June 1, 2026 07:19
The reflection apply path for OpCopy resolved the source value and called
Set with the live reflect.Value, which for reference types (slices, maps,
pointers) made the destination share the source's backing storage.
Subsequent mutations to either side leaked into the other.

OpMove already handled this correctly by allocating a fresh value before
Set; OpCopy now does the same via icore.DeepCopyValue so the destination
owns its storage. Add TestOpCopyDeepCopies covering []int and map fields.
MapKey formatted keys with fmt.Sprintf("%s/%v", ...) directly into the path
string, so a key containing '/' (e.g. "a/b") produced "/M/a/b" — the path
navigator then treated it as two segments and failed with "map key a not
found". '~' suffered the same RFC 6901 layering issue silently.

Apply icore.EscapeKey to the key before joining so '/' becomes "~1" and
'~' becomes "~0", matching ParsePath's unescaping. End-to-end test asserts
both the produced path string and a successful Set through a key with '/'.
Patch.Reverse switched on op.Kind but had no case for OpLog, so a
reversed OpLog became a zero-valued Operation. Since OpAdd is the zero
value of OpKind, the result was an OpAdd at the original log path with
nil New — re-applying the reversed patch would attempt a bogus add.

OpLog has no state effect, so the correct inverse is to drop it. Update
TestPatchReverseExhaustive to expect 5 reversed ops (OpLog skipped) and
add TestPatchReverseOpLogOnly covering the all-log case.
Operation.Old was overloaded: prior value for OpRemove/OpReplace, but
source path string for OpMove/OpCopy. The dual use blocked Reverse from
restoring the displaced value when an OpCopy/OpMove had overwritten an
existing destination — Old was already spoken for as the source path.

Add Operation.From (json "f", omitempty) carrying the source path for
OpMove/OpCopy and reserve Old to always mean "prior value". Move and
Copy constructors, JSON round-trip, Walk-to-Operation flatten,
String(), and the reflection apply path all switch to From. Reverse
now produces OpReplace for OpCopy with a non-nil Old (restoring the
displaced value) and a symmetric OpMove (Path<->From) for OpMove.

Wire-format break: patches serialised with the previous "from" field
encoding via the Old field will not round-trip. Generated *_deep.go
files are unaffected (they never read Operation.From/Old for Move/Copy
— those ops fall through to the reflection path).

Tests cover OpCopy reverse with a captured prior value and the
symmetric OpMove reverse.
TestEngineFailures previously called deep.Apply for both cases without
checking the returned error, so a regression that silently no-op'd these
ops would have gone undetected. Tighten the assertions and add coverage
for the empty-From early-reject path added in the From/Old split.
The generated applyOperation's root-level strict check used an
unchecked op.Old.({{.TypeName}}) assertion. A strict OpReplace at "/"
whose Old carried any other concrete type would panic rather than
report a strict-check failure.

Switch the assertion to comma-ok and report the strict-check error on
either a type mismatch or a value mismatch. Regenerate all in-tree
examples and add TestStrictRootMismatchedOldType to lock in
non-panicking behavior.
The pointer-variant ApplyOpReflection has always replaced a nil logger
with slog.Default. The value-variant ApplyOpReflectionValue did not,
so any caller invoking it directly with a nil logger and an OpLog op
would NPE on logger.Info.

Apply the same nil-check at the top of the value variant. Add
TestApplyOpReflectionValueNilLogger covering the OpLog+nil-logger path.
Clock.Latest is exported for serialisation, but direct field writes
bypass the clock's internal mutex and race with concurrent Now/Update.
The CRDT package's UnmarshalJSON assigned Latest directly, leaving the
race detector to fail any test that decoded a CRDT while another
goroutine was editing it.

Add Clock.SetLatest, which takes the mutex before assigning, and route
CRDT.UnmarshalJSON through it. Document Latest as exposed only for
serialisation. Add a race-targeted test that exercises SetLatest
alongside concurrent Now calls.
Clock.Reserve took n as int and assigned int32(n) into the Logical
counter, so a reservation that would push Logical past math.MaxInt32
silently wrapped around. The wrap is invisible at the call site but
breaks HLC monotonicity and therefore causal ordering.

Pre-check n against the remaining int32 headroom and panic if Reserve
would overflow; also reject negative n. Both states are programmer
errors and far less harmful as a panic than as a silent wraparound.
Add tests covering both panics.
Set.Len delegated to len(s.Items()), which materialised the full
deduplicated slice of live elements just to take its length. Inline
the dedup against a single map and return its size instead — same
result, no slice allocation.

Test asserts Len agrees with len(Items) across a churn pattern that
includes duplicates and tombstones.
Any {"op":"test","path":"/","if":...} entry in the JSON Patch document
was consumed as the global Guard, so a user-authored second such entry
silently overwrote whatever Guard the leading entry already produced.

Restrict the heuristic to the document's first entry, matching what
ToJSONPatch emits. Document the wire convention on ParseJSONPatch.
Test asserts a trailing same-shape entry leaves Guard untouched.
A Not condition with no Sub fell through the early-return branch and
continued into the path-based comparison logic, which would silently
mis-resolve the empty Path or produce a confusing error from a
downstream operator. Convert the malformed-input case into an explicit
error at the top of the Not branch so callers see a clear diagnostic.
Investigating the review note about Set.Add advancing the HLC twice
(once for the tag, once inside Edit for the Delta timestamp) showed
neither order produces a single-tick path: Edit always ticks for any
non-empty patch, and Add always produces a non-empty patch. The
duplicate tick is harmless under the HLC mutex.

Drop the open question by documenting the design on Add so future
readers don't re-spend the analysis budget.
@brunoga brunoga merged commit 991e5ad into main Jun 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant