Skip to content

fix(osc): refuse non-finite skip/locate arguments at the mapping boundary - #848

Merged
one-soft-engineer merged 3 commits into
devfrom
issues/845
Sep 15, 2026
Merged

one-soft-engineer merged 3 commits into
devfrom
issues/845

Conversation

@one-soft-engineer

Copy link
Copy Markdown
Collaborator

Summary

A /onlycue/skip or /onlycue/locate carrying a NaN or infinite float argument now yields no command, the same as a string argument already does. Before, the non-finite value flowed through to the dispatcher, where Swift's max and .NET's Math.Max resolve it differently.

Closes #845

Root Cause

OSCParser passes float arguments through unfiltered — correctly; golden/osc-v1.json pins the NaN bit patterns deliberately (#833). OSCCommand.from(_:) then accepted any numericValue, so seconds could be NaN or ±∞ by the time it reached OSCServerHost.resolvedSeekTime:

case .skip(let seconds): max(0, currentTime + seconds)

The two platforms' max do not agree, and Swift's is not even symmetric:

Expression Result
Swift max(0.0, Double.nan) 0.0
Swift max(Double.nan, 0.0) nan
.NET Math.Max(0.0, double.NaN) NaN
.NET Math.Max(double.NaN, 0.0) NaN

Swift's max is y >= x ? y : x, and NaN >= 0 is false — so it silently returns the other argument. The same datagram would rewind the playhead to 0 on macOS and produce a NaN seek destination on a naive C# port.

Not a live parity break today: resolvedSeekTime is UI-layer and has not been ported (OnlyCue.App.ViewModels arrives in M1e). This closes the hazard before the port can inherit it.

Fix Description

Reject non-finite arguments at the mapping boundary (OSCCommand / OscCommand), not at the dispatcher.

Three reasons for that boundary rather than fixing max:

  1. Reordering the max arguments would paper over it — it happens to give the .NET answer on that one line, and the next max someone writes reintroduces the divergence.
  2. isFinite / double.IsFinite are the same IEEE-754 predicate (not NaN, not ±∞). Unlike max, they cannot drift apart between runtimes, so the rule is pinnable by a golden vector rather than being a per-call-site discipline.
  3. It is the less surprising answer on macOS's own terms: a sender that skips by NaN did not ask to rewind to the top.

The check deliberately did not go inside OSCMessage.numericValue / OscMessage.NumericValue. A float32 NaN argument does have a numeric value — it's NaN — and those are parser-layer accessors. Narrowing their meaning would be a trap for any future command that wants to see the raw value.

Int32 arguments widen to a finite double always, so this only ever bites floats. resolvedSeekTime is untouched: it can no longer be reached with a non-finite seconds.

Changes

  • OnlyCue/OSC/OSCCommand.swift — new numeric(_:_:) helper (mirroring the shape of C#'s Numeric) replaces the two inline numericValue.map arms and adds seconds.isFinite.
  • windows/OnlyCue.Core/Osc/OscCommand.cs — Numeric gains && double.IsFinite(seconds).
  • OnlyCueTests/OSCCommandTests.swift — five tests under a new #845 section (red commit 6c48b2a; four failed before the fix).
  • OnlyCueTests/OSCGoldenFixtures.swift + golden/osc-v1.json — new -∞ case; the three existing non-finite cases now record command: null while still pinning the argument bit-for-bit.
  • docs/architecture.md — the OSC section states the rule and why it lives at the mapping boundary.

How to Test

  • xcodebuild test -only-testing:OnlyCueTests/OSCCommandTests -only-testing:OnlyCueTests/OSCGoldenVectorTests -only-testing:OnlyCueTests/OSCParserTests — green
  • cd windows && dotnet test — 554 passed
  • swiftlint --strict — 0 violations, 716 files
  • Delete golden/osc-v1.json and re-run: it regenerates byte-identical to the committed file (bootstrap exits 65 by design)
  • Send /onlycue/skip ,f <NaN> at a running app — the monitor still shows the message; the playhead does not move

Mutation testing

# Mutant Result
S1 Swift drops isFinite killed — 4 unit tests + golden drift
S2 Swift uses !isNaN (lets ±∞ through) killed — 2 unit tests + golden drift
S3 Swift rejects every numeric argument killed — 5 unit tests + 2 golden tests
C1 C# drops double.IsFinite killed — 4 golden cases
C2 C# uses !double.IsNaN killed — 2 golden cases

5 mutants, 5 killed, 0 survivors. S2/C2 are the ones worth having: they prove the vectors distinguish "not NaN" from "finite", which is the mistake a porter is most likely to make. S3 is the over-rejection guard — test_skip_withHugeButFiniteArgument_stillMaps exists so that "reject non-finite" cannot quietly become "reject implausible".

Regression Risk

Low. A skip/locate whose first argument is NaN or ±∞ previously produced a seek (to 0 on macOS, by the max accident described above) and now produces nothing. No sender legitimately emits one. Finite arguments, int arguments, all other addresses, parsing, and the monitor tail are unchanged.

UI tests do not run on PRs in this repo (gated on push-to-dev), so this check does not cover XCUITests; OSC command mapping has no UI-test surface.


OnlyCue verification (required)

Spec link: docs/architecture.md#osc-remote-control
Closes: #845 (also updates parent Epic task list)

  • New tests added for every behavior (TDD: red→green committed)
  • Gherkin scenarios from the issue mapped to UI tests where applicable — n/a, pure mapping function
  • Spec updated if behavior diverged from docs/
  • CI green

@one-soft-engineer
one-soft-engineer merged commit 15a6719 into dev Sep 15, 2026
2 checks passed
@one-soft-engineer
one-soft-engineer deleted the issues/845 branch September 15, 2026 12:55
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.

OSC skip with a NaN argument resolves differently on macOS and .NET

2 participants