Skip to content

pxf: lexer cannot read fractional or µs duration literals (1.5ms, 2µs) that other ports' encoders write #20

Description

@trendvidia

What

The PXF lexer cannot read fractional or µs duration literals — 1.5ms, 1.234567ms, 1.5h, 2µs, 312.5µs, 1h30m0.5s — even though draft-trendvidia-protowire-01 §3.3 admits all of them:

duration-segment = 1*DIGIT [ "." 1*DIGIT ] time-unit
time-unit        = "ns" / "us" / micro-us / "ms" / "s" / "m" / "h"
micro-us         = %xC2.B5 %x73    ; UTF-8 of "µs"

and §3.10 lists 1.5h and 2µs as examples. Two defects, both inherited from the Go reference lexer (fixed there in trendvidia/protowire-go#76 for trendvidia/protowire-go#75):

  1. Ordering. After the integer digits, lexNumber checks for . / e / E (→ float) before it checks for a time unit, so 1.5ms tokenises as FLOAT 1.5 followed by IDENT ms, and the decoder then reports the message-typed Duration field as receiving a scalar (expected '{' or this port's equivalent).
  2. ASCII-only unit scan. The duration scan accepts only digits and a-z, so the two-byte UTF-8 µ (U+00B5, C2 B5) never joins the token and 2µs becomes INT 2 plus a stray byte.

Why it matters

These are exactly the forms the Go, Rust, C++ and TypeScript encoders write for a google.protobuf.Duration that is not a whole multiple of its largest unit — i.e. every measured latency — because they all mirror time.Duration.String() (1.234567ms, 312.5µs, 1h30m0.5s, -2.5s). A document produced by any of those ports with such a value cannot be read by this one.

Where

src/pxf/lexer.cc:

  • ~L521–525: if (... Peek() == '.' || Peek() == 'e' || Peek() == 'E') return LexFloat(...) runs before IsDurationUnit(Peek()).
  • LexDuration (~L560) scans IsDigit || IsLowerAlpha only; detail::ParseDuration (src/detail/duration.cc) already understands C2 B5 and fractions, so validation is not the blocker, tokenisation is.

Encoder: src/detail/duration.cc (~L164–177) writes %lldµs and %lld.%sµs, so this port cannot read its own output for measured values — same self-inflicted shape as Go.

protowire-python wraps this library (_protowire via nanobind, protowire_pxf target) and inherits both the bug and the fix.

Fix, as landed in Go (trendvidia/protowire-go#76)

  • In the number path: after the leading digits, consume an optional fraction (. followed by ≥1 digit) first; then, if a unit start follows (ASCII h m s n u, or the byte pair C2 B5), take the duration branch; otherwise fall through to float (fraction consumed, or exponent, or bare trailing .), else INT.
  • In the duration scan: also accept . when followed by a digit, and the C2 B5 pair; keep validating the whole literal with the port's Go-duration parser afterwards.
  • Deliberately unchanged in Go, worth pinning here too: 1.5 → FLOAT; 1.5x → FLOAT + IDENT x; 1.ms → FLOAT 1. + IDENT (no digit after ., so not a duration-segment); 1.5e3ms → FLOAT + IDENT (an exponent is not a unit); 5min / 1.5min → invalid duration; U+03BC GREEK SMALL LETTER MU (CE BC) is not a unit — only U+00B5 is in the grammar, even where the underlying duration parser would accept both.

Tests to port from Go: the 47-case token table in encoding/pxf/lexer_duration_test.go (positive, negative, and token-boundary cases), and the property test TestMarshalDurationReadsBack (marshal every Duration.String() branch, read it back to identical seconds/nanos) in encoding/pxf/full_roundtrip_test.go.

Diagnosis is from code reading of the checked-out lexer, not a runtime probe; the line refs above are the two decisions to look at.

Cross-port tracker: filed against every port from the trendvidia/protowire-go#75 session — protowire-rust, protowire-java (Kotlin wraps it), protowire-typescript, protowire-cpp (Python wraps it), protowire-csharp, protowire-dart, protowire-swift.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions