Skip to content

Report static_delay_ms as the spec-conformant wire type - #169

Merged
chrisuthe merged 1 commit into
mainfrom
fix/static-delay-wire-conformance
Aug 12, 2026
Merged

Report static_delay_ms as the spec-conformant wire type#169
chrisuthe merged 1 commit into
mainfrom
fix/static-delay-wire-conformance

Conversation

@chrisuthe

Copy link
Copy Markdown
Member

Answering "are we following the spec on setting delay?" — the inbound set_static_delay path is conformant; the outbound reporting of static_delay_ms was not, three ways.

What was already right

player@v1_support.supported_commands = ["volume","mute"] and client/state's = ["set_static_delay"], which are two different vocabularies in the spec and are correctly kept apart. Inbound commands are ignored unless advertised, clamped to 0–5000, and persisted; ServerCommandMessage.StaticDelayMs is already int?. LoadPersistedStaticDelay runs on every handshake, satisfying "persist across reboots and server reconnections".

The three deviations

All outbound, all from one conflation: the client reported the scheduler's value directly. That is a double over −5000…5000 — fractional from calibration, negative to schedule later — while the spec's static_delay_ms is an integer in 0–5000 that "does not support" negatives.

1. Omitted at its default, and the default is 0. PlayerStatePayload carried JsonIgnoreCondition.WhenWritingDefault, so nearly every player left it out of its initial client/state. The spec marks it REQUIRED for players — identically to required_lead_time_ms and min_buffer_ms, which sit immediately beside it and are serialized unconditionally with a comment saying it is because they are required. aiosendspin's own docstring: "Required on the initial state message; omitted in incremental updates means unchanged" — so on the first message an omission leaves the server with no value rather than 0.

2. Serialized as a float. Measured, not inferred:

ZERO:  ..."player":{"volume":100,"muted":false,"required_lead_time_ms":200,...}   <- no static_delay_ms
FRAC:  ..."static_delay_ms":12.5,...
WHOLE: ..."static_delay_ms":250,...

3. Negatives reached the wire. MinStaticDelayMs = -5000.0, deliberately, for GroupSync offset semantics. But aiosendspin raises:

if self.static_delay_ms is not None and not 0 <= self.static_delay_ms <= 5000:
    raise ValueError(f"static_delay_ms must be in range 0-5000, got {...}")

So unlike 1 and 2 — which mashumaro silently absorbs, truncating 12.5 to 12 — a negative fails the connection.

The fix: project, do not narrow

IClockSynchronizer.StaticDelayMs is unchanged. The scheduler genuinely needs negatives and fractions, and a negative delay still schedules audio later. Only the report is constrained, by a single ToWireStaticDelayMs that clamps, rounds, guards non-finite (the setter is public and Math.Clamp propagates NaN into a garbage cast), and warns once per value when the projection moves it — because at that point the server's group calibration is working from a different number than playback is.

PlayerStatePayload.StaticDelayMs and both ClientStateMessage factories now take int. That is deliberate: it makes the compiler find every site that must project. There were exactly two — and one fact reported from two places with only one updated is this codebase's recurring defect, so having the type system enforce it beats a convention.

Verification

  • Clean dotnet build -c Release --no-incremental: 0 errors, both library TFMs.
  • Full suite: 751 passed, 0 failed (735 + 16 — count checked).
  • Both halves proven to discriminate independently:
    • Projection replaced by a plain (int) cast → 7 of 12 fail.
    • Required-field fix reverted (double + WhenWritingDefault) → 10 of 18 fail.
  • The theory covers 0, whole, 12.5→13, 12.4→12, −200, −5000, 9000, NaN, +∞, with a separate positive control asserting the field is present at 0 — otherwise every zero-expecting case would fail on a missing property, which reads as a different bug.
  • SchedulerKeepsTheConfiguredValue_TheProjectionIsWireOnly pins that the clamp does not write back, and the delta path is pinned separately from the initial-state path rather than assumed to share it.

Docs: MIGRATION-10.0.0.md §8 and the NuGet README's static-delay section.

client/state reported the scheduler's static delay directly. That value is a
double over -5000..5000 - fractional from calibration, negative to schedule
later - while the spec types static_delay_ms as an integer 0-5000 and states
negatives are not supported. Three deviations followed from the one conflation,
all outbound; the inbound set_static_delay path was already correct (int?,
clamped 0-5000, gated on advertisement, persisted).

1. Omitted at its default. PlayerStatePayload carried
   JsonIgnoreCondition.WhenWritingDefault, and the default is 0 - the common
   case - so nearly every player left it out of its initial client/state. The
   spec marks it REQUIRED for players, identically to required_lead_time_ms and
   min_buffer_ms, which sit beside it and are serialized unconditionally for
   exactly that reason. aiosendspin reads an omitted value as "unchanged", which
   on the initial message leaves it with no value at all.
2. Serialized as a float. A fractional delay emitted 12.5.
3. Negatives reached the wire. aiosendspin's PlayerStatePayload raises
   ValueError("static_delay_ms must be in range 0-5000"), so this failed the
   connection rather than being tolerated.

Projected at the wire boundary instead of narrowing the scheduler: IClock-
Synchronizer.StaticDelayMs is unchanged, negative delays still schedule audio
later, and only the report is constrained. ToWireStaticDelayMs clamps, rounds,
guards non-finite (the setter is public and Math.Clamp propagates NaN), and warns
once per value when the projection moves it - the server's group calibration is
then working from a different number than playback is.

PlayerStatePayload.StaticDelayMs and the two ClientStateMessage factories take
int, so the compiler locates every call site that must project. There were
exactly two, which is the point: this codebase's recurring defect is one fact
reported from two places with only one of them updated.

Verified by measuring the serializer, not by reading attributes. Discrimination:
7 of 12 fail with the projection replaced by a plain cast, 10 of 18 with the
required-field fix reverted. Suite 751 green, clean Release build on both TFMs.
@chrisuthe
chrisuthe merged commit f080494 into main Aug 12, 2026
5 checks 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