Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/technical/DIAGNOSTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ omitted. Only explicit IPv4 loopback endpoint/port text survives. Windows facts
are numeric major/minor/build values from the native version API, without a
machine name or hardware inventory.

Validated GSPro 202 and 203 notifications emit fixed
`gspro_notification_202_observed_not_receipt` and
`gspro_notification_203_observed_not_receipt` events. These record only the
allowlisted code classification, without response text, player values or shot
data. They do not prove shot acceptance, readiness or visible flight. A generic
`target_response_profile_invalid` still does not identify the rejected code.

Installer output uses selected operation/boolean/numeric/source-revision fields from
`setup-result.log`; it excludes the verbose MSI log and debug/authentication
text, including hexadecimal UI records. No MSI API is called. A result recorded
Expand Down
9 changes: 5 additions & 4 deletions docs/technical/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ numeric `TotalSpin` if that member is supplied, otherwise both numeric
zeros, conversion, or a success ACK.

GSPro shot bytes are also sent unchanged plus LF. Its response profile admits
200, 201, and terminal failures 500 through 599. Other codes, including 202
and 203, invalidate the destination instead of creating a ready state.
Acceptance of these fields, framing, and responses by an installed GSPro
build remains unqualified.
200, player information 201, notifications 202 and 203, and terminal failures
500 through 599. Notifications are relayed unchanged without settling a shot
receipt, closing the connection, or setting the bridge's readiness state.
Other codes invalidate the destination. Acceptance of shot fields, framing,
and receipts by an installed GSPro build remains unqualified.

## Split shots and duplicate suppression

Expand Down
8 changes: 8 additions & 0 deletions internal/bridge/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,14 @@ func (e *engine) handle(ev event) {
e.wait.receipt = ev.frame
e.acceptReceipt()
} else {
if e.dst.profile == destination.GSPro {
switch code {
case 202:
e.log("gspro_notification_202_observed_not_receipt", 0)
case 203:
e.log("gspro_notification_203_observed_not_receipt", 0)
}
}
if e.switching {
e.recordReplyDiscard(code, "destination_switch_pending")
} else {
Expand Down
103 changes: 103 additions & 0 deletions internal/bridge/destination_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,109 @@ func TestInitialDestinationUsesOnlySelectedProfile(t *testing.T) {
}
}

func TestGSProNotificationsPreserveSessionWithoutAcceptingShots(t *testing.T) {
h := configuredHarness(t, true, nil, nil, func(config *Config) {
config.Destination = destination.GSPro
})
target := h.target()
notifications := []string{
`{"Code":202,"Message":"synthetic ready","Player":null,"Extra":-0.00}`,
`{"Code":203,"Message":"synthetic round ended","Player":null}`,
}
relayNotifications := func() {
t.Helper()
target.send(t, []byte(strings.Join(notifications, "")))
for _, want := range notifications {
if got := string(h.labs.frame(t)); got != want {
t.Fatal("notification bytes changed", got)
}
}
}
relayNotifications()
assertOneTargetConnection(t, h, 0)
for index, id := range []string{"1", "2"} {
previous := uint64(index)
want := gameShot(id)
h.labs.send(t, want)
if !bytes.Equal(target.frame(t), want) {
t.Fatal("notification changed shot bytes or eligibility")
}
h.wait(func(s Snapshot) bool { return s.Counts.Sent == previous+1 })
relayNotifications()
state := h.state(false)
if state.Counts.TargetAccept != previous || state.Counts.SourceACK != previous ||
state.Ready != "unknown" || state.TargetGeneration != 1 || h.fake.dials.Load() != 1 {
t.Fatal("notification settled a receipt, declared ready or retired the session", state)
}
h.labs.silent(t, 10*time.Millisecond)
target.send(t, []byte(acceptance))
if got := string(h.labs.frame(t)); got != acceptance {
t.Fatal("actual receipt changed")
}
assertOneTargetConnection(t, h, previous+1)
relayNotifications()
assertOneTargetConnection(t, h, previous+1)
}
}

func TestNotificationClassificationRetainsPendingReceiptAndExactRelay(t *testing.T) {
for _, profile := range []destination.ID{destination.GSPro, destination.InfiniteTees} {
for _, code := range []int{202, 203} {
t.Run(fmt.Sprintf("%s/%d", profile, code), func(t *testing.T) {
e, _ := stateEngine(t)
e.dst = &target{gen: 7, profile: profile, cancel: func() {}}
e.wait = &pending{token: 11, alias: 1, written: true}
e.state.Ready = "unknown"
frame := []byte(fmt.Sprintf(`{"Code":%d,"Message":"PRIVATE-MESSAGE","Player":{"Handed":"PRIVATE-PLAYER"},"Unknown":-0.00}`, code))
e.handle(event{kind: "target_frame", epoch: 3, gen: 7, token: 11, frame: frame})
if e.dst == nil || e.wait == nil || e.wait.token != 11 || e.wait.receipt != nil ||
e.state.Counts.TargetAccept != 0 || e.state.Counts.SourceACK != 0 ||
e.state.Counts.Uncertain != 0 || e.state.Ready != "unknown" {
t.Fatal("notification retired session, settled receipt or declared readiness", e.state)
}
if e.src.queuedCount != 1 || !bytes.Equal((<-e.src.writes).frame, frame) {
t.Fatal("notification relay changed bytes")
}
if profile == destination.GSPro {
want := fmt.Sprintf("gspro_notification_%d_observed_not_receipt", code)
if len(e.state.Recent) != 1 || e.state.Recent[0].Reason != want || e.state.Recent[0].Alias != 0 {
t.Fatal("notification classification missing or contains private values", e.state.Recent)
}
} else if len(e.state.Recent) != 0 {
t.Fatal("GSPro notification diagnostics changed Infinite Tees")
}
})
}
}
}

func TestGSProNotificationCannotReplaceReceiptOrReplayTimedOutShot(t *testing.T) {
h := configuredHarness(t, true, nil, nil, func(config *Config) {
config.Destination = destination.GSPro
})
target := h.target()
h.labs.send(t, gameShot("1"))
target.frame(t)
h.wait(func(s Snapshot) bool { return s.Counts.Sent == 1 })
notification := []byte(`{"Code":202,"Message":"synthetic ready","Player":null}`)
target.send(t, notification)
if !bytes.Equal(h.labs.frame(t), notification) {
t.Fatal("notification relay changed")
}
h.wait(func(s Snapshot) bool { return s.Counts.Uncertain == 1 })
recovered := h.target()
recovered.silent(t, 10*time.Millisecond)
h.labs.silent(t, 10*time.Millisecond)
h.labs.send(t, gameShot("1"))
h.wait(func(s Snapshot) bool { return s.Counts.Duplicate == 1 })
recovered.silent(t, 10*time.Millisecond)
state := h.state(false)
if state.Counts.Sent != 1 || state.Counts.TargetAccept != 0 || state.Counts.SourceACK != 0 ||
state.Ready != "unknown" || state.Source != "attached" {
t.Fatal("notification replaced receipt or timed-out shot replayed", state)
}
}

func TestGameSwitchSettlesOldReceiptRetainsSourceAndNeverFansOut(t *testing.T) {
addresses := make(chan string, 8)
h := configuredHarness(t, true, nil, nil, func(config *Config) {
Expand Down
7 changes: 4 additions & 3 deletions internal/destination/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,9 @@ func number(raw []byte) bool {

// Response returns a response code permitted by id's profile or
// [protocol.ErrShape]. InfiniteTees uses [protocol.Response]; GSPro permits
// 200, 201, and 500 through 599 after envelope validation. Standby and invalid
// IDs reject responses. A 200 code alone is not a game-ready signal.
// 200 through 203 and 500 through 599 after envelope validation. Notifications
// 201 through 203 neither accept a shot nor establish game readiness. Standby
// and invalid IDs reject responses.
func (id ID) Response(frame []byte) (int, error) {
if id.Default() == InfiniteTees {
return protocol.Response(frame)
Expand All @@ -129,7 +130,7 @@ func (id ID) Response(frame []byte) (int, error) {
return 0, protocol.ErrShape
}
code, err := protocol.ResponseCode(frame)
if err != nil || (code != 200 && code != 201 && (code < 500 || code > 599)) {
if err != nil || ((code < 200 || code > 203) && (code < 500 || code > 599)) {
return 0, protocol.ErrShape
}
return code, nil
Expand Down
22 changes: 20 additions & 2 deletions internal/destination/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ func TestResponseProfilesDoNotInventReadinessOrBroadenIT(t *testing.T) {
code string
it, gs bool
}{
{"200", true, true}, {"201", true, true}, {"202", true, false},
{"203", true, false}, {"500", true, true}, {"501", true, true},
{"200", true, true}, {"201", true, true}, {"202", true, true},
{"203", true, true}, {"204", false, false}, {"500", true, true}, {"501", true, true},
{"503", false, true}, {"599", false, true}, {"600", false, false},
{`"200"`, false, false}, {"200.0", false, false},
} {
Expand All @@ -93,6 +93,24 @@ func TestResponseProfilesDoNotInventReadinessOrBroadenIT(t *testing.T) {
}
}

func TestGSProNotificationsStillRequireStrictResponseEnvelopes(t *testing.T) {
for _, code := range []string{"202", "203"} {
for _, bad := range []string{
`{"Code":` + code + `}`,
`{"Code":` + code + `,"Message":null}`,
`{"Code":` + code + `,"Message":"synthetic","Player":[]}`,
`{"Code":` + code + `,"Code":` + code + `,"Message":"synthetic"}`,
`{"Code":"` + code + `","Message":"synthetic"}`,
`{"Code":` + code + `.0,"Message":"synthetic"}`,
`{"Code":` + code + `,"Message":"synthetic","Extra":{"x":1,"x":2}}`,
} {
if _, err := GSPro.Response([]byte(bad)); err == nil {
t.Fatal("notification bypassed strict response validation", bad)
}
}
}
}

func TestPreferenceIsBoundedExplicitAndReplacedAtomically(t *testing.T) {
p := &Preferences{Path: filepath.Join(t.TempDir(), "settings", "destination.json")}
if id, err := p.Load(); err != nil || id != InfiniteTees {
Expand Down
1 change: 1 addition & 0 deletions internal/diagnostics/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ source_attached previous_source_epoch_actual_response_write_completed previous_s
departed_source_socket_cleanup_failed admitted_upstream_write_completed_after_target_retirement
source_200_complete_local_write_not_peer_consumption target_connected_ready_unknown_new_shots_only
target_unavailable target_write_partial_or_uncertain target_response_profile_invalid
gspro_notification_202_observed_not_receipt gspro_notification_203_observed_not_receipt
unsolicited_or_duplicate_200_discarded target_reported_failure_no_success_ACK
target_receipt_deadline_delivery_uncertain delivery_uncertain_never_replay held_shot_target_generation_lost
source_listener_failed platform_fault_waiting_for_source_departure identity_unavailable_pending_source_departure
Expand Down
7 changes: 6 additions & 1 deletion internal/diagnostics/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func TestMultiGameEventsRemainActionableWithoutBroadeningRedaction(t *testing.T)
"destination_change_already_pending", "destination_change_blocked_quit", "destination_change_blocked_source_fault",
"invalid_destination", "upstream_response_discarded_destination_switch_pending",
"destination_change_pending", "destination_change_failed", "destination_preference_save_failed",
"gspro_notification_202_observed_not_receipt", "gspro_notification_203_observed_not_receipt",
}
for _, code := range codes {
if err := logger.WriteEvent(bridge.Event{At: time.Now().UTC(), Reason: code}); err != nil {
Expand All @@ -141,6 +142,9 @@ func TestMultiGameEventsRemainActionableWithoutBroadeningRedaction(t *testing.T)
if err := logger.WriteEvent(bridge.Event{Reason: "destination_change_PRIVATE_DEVICE", Alias: 987654321}); err != nil {
t.Fatal(err)
}
if err := logger.WriteEvent(bridge.Event{Reason: "gspro_notification_202_PRIVATE_PLAYER", Alias: 987654321}); err != nil {
t.Fatal(err)
}
if err := closeLogger(t, logger); err != nil {
t.Fatal(err)
}
Expand All @@ -153,7 +157,8 @@ func TestMultiGameEventsRemainActionableWithoutBroadeningRedaction(t *testing.T)
t.Fatal("reviewed multi-game event was hidden as unknown", code)
}
}
if bytes.Contains(data, []byte("PRIVATE_DEVICE")) || bytes.Contains(data, []byte("987654321")) ||
if bytes.Contains(data, []byte("PRIVATE_DEVICE")) || bytes.Contains(data, []byte("PRIVATE_PLAYER")) ||
bytes.Contains(data, []byte("987654321")) ||
!bytes.Contains(data, []byte("unrecognized_event_redacted_")) {
t.Fatal("destination event prefix bypassed privacy redaction")
}
Expand Down