Skip to content

macOS: fix FX Pak Pro dropping out during play (desync/timeout fixes + disable App Nap) - #1

Open
CVW-HMB wants to merge 12 commits into
mainfrom
macos-fxpak-fixes
Open

CVW-HMB wants to merge 12 commits into
mainfrom
macos-fxpak-fixes

Conversation

@CVW-HMB

@CVW-HMB CVW-HMB commented Sep 14, 2026

Copy link
Copy Markdown
Owner

What this does

Fixes SNI losing communication with an FX Pak Pro on macOS after some minutes of play (killing MSU audio and auto-tracking), where a restart only helps temporarily and the identical setup is stable on Windows.

It combines two independent pieces:

1. Upstream fxpakpro-desync-and-timeout-fixes (the maintainer's hardware-tested work)

These commits target the device wedging itself and one stalled transfer poisoning every request that follows:

  • fix protocol desyncs and unbounded I/O that wedge the device — LS/PUT/GET error paths no longer abandon a data phase the firmware already committed to, and writes now run under a timeout on their own goroutine instead of blocking forever inside WriteFile while holding d.lock.
  • do not block closing a port after abandoning a write — on macOS close() blocks until stuck I/O completes, so the abandon-close now runs on its own goroutine and is idempotent.
  • retry opening a busy port after an abandoned write, report get/put command errors as fatal, plus a large new test suite and a snitest gRPC repro harness.
  • New config knobs: fxpakpro_read_timeout, fxpakpro_write_timeout, fxpakpro_honor_caller_deadline, fxpakpro_chunk_delay.

This branch also brings main current: the SNI proxy driver (alttpo#55) and XDG config-dir support (alttpo#54). An existing ~/.sni directory is still used if present, so the config location does not change for current users.

2. New: disable macOS App Nap (cmd/sni/power)

SNI presents only a status-bar icon with no window, so macOS is free to place it under App Nap while a game is in the foreground. App Nap throttles and coalesces a background app's timers and threads; a napped SNI stops servicing the FX Pak's USB stream promptly, so the device appears to stop responding until SNI is restarted. This is the most likely reason it is macOS-specific — Windows has no equivalent throttling, and the upstream fix branch itself notes the write stall surfaced "on macOS, sitting for minutes with no CPU time."

power.DisableAppNap takes a process-level NSProcessInfo activity assertion (NSActivityUserInitiatedAllowingIdleSystemSleep) for the process lifetime. That keeps SNI out of App Nap while still letting the machine sleep normally. It is behind a build tag and is a no-op on non-darwin platforms.

The two pieces are complementary: the upstream fixes let SNI survive and reconnect after a wedge; the App Nap change keeps the background process from being throttled into that state in the first place.

Testing

  • go build ./cmd/sni and go vet ./... clean on darwin/arm64 (macOS 15.7, Go 1.24).
  • go test -short ./... passes, including the new fxpakpro suite.
  • Installed binary boots, stamps v0.0.99+macos-fxpak-fixes, and logs the new fxpak timeouts. Hardware soak on the FX Pak still to be confirmed in real play.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RVRWxcANar3DxSRTsvPPMu

JamesDunne and others added 12 commits August 27, 2026 21:52
Chasing a report of large PutFile transfers failing part way through --
with SNI appearing to freeze afterwards -- turned up several distinct
problems, all confirmed against real hardware.

Error paths abandoned a data phase the firmware had already committed to.
usbint_handler_cmd picks its next state before it knows whether the
command succeeded, so:

  * LS of a missing directory still emits a block holding the 0xFF
    terminator. Returning on the error code left it in the pipe, so the
    next command read it as its response header and everything after was
    out of step. Drain it before returning.

  * PUT sets cmdDat=1 in usbint_recv_block before f_open is even
    attempted, so a failed PUT parks the device in HANDLE_LOCK awaiting
    the payload. Returning without sending it meant the next command's
    bytes were consumed as file data, after which f_write on the failed
    handle returns zero bytes written forever inside the USB interrupt
    handler -- a wedge needing a physical power cycle. Report it as fatal
    so autoCloseableDevice reconnects within the one command of slack the
    firmware allows; usbint_check_connect() resets its state on the
    disconnect.

  * GET has the same shape but cannot be recovered from the host: the
    firmware spins in usbint_handler_dat on a size taken from a FILINFO
    that a failed f_stat never wrote. Report it as fatal with an
    explanatory message so the failure is attributed here rather than to
    whatever request came next.

Writes were unbounded. go.bug.st/serial sets WriteTotalTimeoutConstant to
0 on Windows, which Win32 defines as "wait forever", and its Port
interface exposes no SetWriteTimeout. A device that stopped draining its
USB endpoint therefore hung the caller indefinitely while it held d.lock,
blocking every other request for that device -- caught in a goroutine dump
sitting 29 minutes inside WriteFile. Writes now run on their own goroutine
under a timeout, and abandoning one closes the port so the orphan cannot
interleave with whatever the caller does next.

sendSerialProgress also assigned writeExact's error and carried on, so the
next iteration overwrote it and a chunk that failed to send was reported
as a successful transfer.

The timeouts are configurable: fxpakpro_read_timeout,
fxpakpro_write_timeout, and fxpakpro_honor_caller_deadline, the last
controlling whether a caller's deadline aborts I/O already in flight.

Also in here: openPort walked all 14 baud rates on errors that had nothing
to do with speed, taking ~8 minutes to fail against a wedged device; the
caller's context now reaches the write path through sendSerialProgress;
stale buffers are flushed on open so a leftover block cannot desync the
first command after a reconnect; and Init's hardcoded 2s budget now
follows the configured read timeout, since it bounds writes as well now
and too tight a value would close healthy devices.

None of this stops the device wedging. A PUT onto a card that runs out of
space mid-transfer spins the firmware in its interrupt handler with no
error code the host can observe; these changes turn that from a silent
indefinite freeze into a prompt error naming the byte offset it stopped at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLkt3NCnrwBMvpP8eAfDR
Unit tests, which need no hardware:

  * sendserial_test.go covers the swallowed write error and the write
    timeout. The write-error case needs a transient failure to be
    meaningful: a permanently broken port leaves err set on the final
    loop iteration, so it gets returned by accident and the bug hides.
    Against the unfixed code this returns a nil error with sent=7680 of
    8192 -- one chunk silently dropped.
  * driver_config_test.go covers the timeout settings, including that a
    non-positive value is rejected rather than applied, which would
    otherwise reintroduce the unbounded wait.
  * openport_test.go asserts one baud rate is tried, not fourteen.

Hardware tests, which skip when no fxpakpro is attached:

  * stress_test.go interleaves PUT/GET/LS/MKDIR/RM and memory reads in a
    seeded random order at sizes deliberately off the 512 byte block
    boundary. Every operation is logged so a failure can be replayed with
    SNI_TEST_SEED, and on failure it probes whether the device was merely
    slow, lost the command, or is wedged. Mixing VGET in matters: it uses
    a 64 byte command block where the filesystem commands use 512, and the
    firmware only re-evaluates cmd_size when recv_buffer_offset crosses 64
    from below.
  * errorpath_test.go issues a failing ls/get/put and checks whether the
    stream is still aligned afterwards, one path per run since a failure
    can wedge the device. putErrorRecovers covers the fix through
    AutoCloseableDevice, the layer grpcimpl uses.
  * diskfull_test.go fills the card to find the boundary, and reports
    which of the two disk-full cases occurred: a clean rejection before
    the data phase, or the unrecoverable mid-transfer stall.
  * putfile_test.go adds large-transfer, write-size and overwrite cases.
    The write-size sweep is what ruled out the original theory that the
    host was outrunning the device: throughput is identical whether SNI
    writes 512 bytes or 64 KiB per call, because the host is already
    blocked on USB NAKs, so inter-chunk sleeps would change nothing.
  * deadline_test.go, largexfer_test.go, bootpath_test.go and
    cleanup_test.go cover the deadline policy, repeated large transfers,
    menu versus in-game state, and clearing leftovers off the card.

device_test.go wires the SNI_FXPAKPRO_* environment variables into the
test binary. Test binaries never call config.Load(), so the settings were
silently inert under test and any config-dependent result would have been
meaningless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLkt3NCnrwBMvpP8eAfDR
Audited every command for whether a device error code leaves the protocol
stream clean. What decides it is the firmware's own next-state choice,
which is made by opcode before it knows whether the command succeeded:

    GET, VGET, LS -> HANDLE_DAT    device is about to send a data phase
    PUT, VPUT     -> HANDLE_LOCK   device is waiting for a payload
    everything else -> IDLE        nothing pending

So fatality should follow whether there is a pending data phase, not how
severe the error sounds. mkdir, rm, mv, boot, reset, menu_reset and info
all land in IDLE and stay non-fatal, which is right: error code 1 is the
device's generic "something went wrong" and for mkdir usually just means
the directory already exists. Nothing is pending, the stream stays
aligned, and tearing the connection down would be both wrong and risky
given how poorly the firmware handles reconnects.

get and put were the two that did not match. Both are only reachable from
tests today -- d.get appears solely in get_test.go and d.put has no
callers -- and for SpaceSNES the firmware cannot set an error code at all.
SpaceCFG can, though, via cfg_get_stringvalue returning not-found, so get
is genuinely reachable if it is ever wired up.

get cannot drain its way out the way ls does: the data phase length comes
from server_info.size, which on the error path holds whatever a previous
command left in the FILINFO rather than a real length. Closing and
reopening is the only reliable recovery.

vget and vput are unaffected; they set FlagNORESP and never read a
response block, so there is no error code to mishandle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLkt3NCnrwBMvpP8eAfDR
abandonPort closed the port synchronously. A Write that is stuck because
the device stopped draining its USB endpoint keeps the handle busy, and on
macOS close() then blocks until that I/O completes -- so the caller hung
inside Close instead of inside Write. The same deadlock the write timeout
exists to prevent, one frame further down, and it triggered exactly when
the timeout was supposed to rescue the caller:

    goroutine 23 [syscall]:
      fxpakpro.(*devicePort).Close
      fxpakpro.abandonPort
      fxpakpro.writeWithTimeout
      fxpakpro.writeExact
      fxpakpro.(*Device).putFile

devicePort.abandon() now marks the port unusable and closes it on its own
goroutine. Write refuses once the port is marked, so nothing can race the
abandoned write, and Close is idempotent so autoCloseableDevice's later
close cannot block behind the same stuck write either.

This was missed on Windows, where the close did not block. It took a soak
on macOS, sitting for minutes with no CPU time, to surface it.

Test_writeWithTimeout_closeDoesNotBlock covers it with a port whose Close
blocks until the write is released.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLkt3NCnrwBMvpP8eAfDR
SNI_TEST_WEIGHTS sets the operation mix, so a run can be shaped to match
real usage. Trackers poll memory with VGET far more than they touch files,
which is worth testing directly: VGET sends a 64 byte command block where
every filesystem command sends 512, and the firmware only re-evaluates
server_info.cmd_size when recv_buffer_offset crosses 64 from below, so
mixing the two is where a framing desync would show up.

Added a memory write op that VPUTs and then reads the value back. A
write-only check would pass on a stream that had desynced into returning
plausible but wrong data.

Writes target cartridge SRAM rather than WRAM. Writes to 0xF50000-0xF70000
are not a plain VPUT: memory.go turns them into a 65816 copy routine driven
through the USB EXE mechanism, which needs the SNES to be running code that
services the hook. Sitting in the system menu nothing does, so they time out
waiting on $2C00. SRAM is also mirrored to a per-game file on the SD card
every 250ms, so these writes generate SD activity too -- useful, since that
is where the firmware does FAT work inside its USB interrupt handler.

Soaked at 62% VGET, 8% VPUT-with-readback and 30% filesystem operations:
8000 operations in 5m24s with no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLkt3NCnrwBMvpP8eAfDR
SNI_TEST_MEM_SPACE selects what memory writes go to. SRAM stays the
default so the test is usable while the device sits in the system menu.

WRAM only works with a ROM running: writes to 0xF50000-0xF70000 are not a
plain VPUT, memory.go turns them into a 65816 copy routine driven through
the USB EXE mechanism, which polls $2C00 waiting for the SNES to service
the hook. That is what a tracker does when it injects state, so it is worth
covering, but it needs a game booted.

The read-back comparison is skipped for WRAM. A running game writes WRAM
constantly, so a value read back can differ from what was written for
entirely legitimate reasons and comparing would produce false failures. The
byte count is still checked, which catches a desync even when the contents
cannot be predicted. SRAM keeps the full comparison, since nothing else
writes it.

Soaked in-game with Super Mario World running, at 62% VGET, 8% WRAM VPUT
and 30% filesystem operations: 12000 operations in 4m8s with no failures.

Also worth recording: the same mix runs at roughly 100 ops/sec in-game
against 25 ops/sec in the system menu. menu_main_loop sleeps 20ms per
usbint_handler() call while the in-game loop in main.c has no sleep at all,
so a device sitting in the menu services USB only about 50 times a second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLkt3NCnrwBMvpP8eAfDR
Pairs with TestDevice_bootPath for putting the device into a known state
between hardware runs. The firmware polls USB very differently in the two
states -- menu_main_loop sleeps 20ms per usbint_handler() call while the
in-game loop does not -- so which state a soak ran in matters when reading
its results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLkt3NCnrwBMvpP8eAfDR
fxpakpro_chunk_delay pauses after each 512-byte chunk written during a
transfer. It defaults to zero, which writes as fast as the device accepts
data, and exists to separate host write pacing from the logging that
happens to accompany it.

A user reported transfers succeeding with SNI_DEBUG=1 and failing without
it, and the obvious theory was that debug logging slowed the host enough
to matter. Measured on Windows, it does not: a 4 MiB upload takes 9.40s
with debug against 9.51s without, because writes are paced by USB NAKs
rather than by the host. This knob makes that testable directly rather
than inferred from a logging side effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLkt3NCnrwBMvpP8eAfDR
Abandoning a stuck write closes the port on another goroutine, because a
synchronous close blocks behind the very write being abandoned. The
orphaned write still holds the handle until it unwinds, and
autoCloseableDevice reopens as soon as the fatal error propagates --
microseconds later -- so the reopen lands while the handle is still held.

Seen in the field on Windows: one stalled transfer poisoned every command
that followed.

    fxpakpro: open(name="/COM3"): Serial port busy
    /DeviceFilesystem/ReadDirectory: err=`... Serial port busy`

openPort now retries for up to 3 seconds at 50ms intervals, but only for
serial.PortBusy. Every other failure still returns immediately, so a
wedged or absent device is not retried for baud rates that were never
going to work.

This does not help if the device has genuinely stopped draining, since the
orphaned write never unwinds and the handle is never released. It fixes
the common case where the close simply had not completed yet, which is
what turned a single failed upload into a stream of unrelated errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLkt3NCnrwBMvpP8eAfDR
cmd/snitest drives SNI over gRPC the way a file transfer client does:
ListDevices, MakeDirectory for each path component, PutFile,
ReadDirectory, GetFile with verification. Every other hardware test in
this repo calls the driver in-process, which skips the gRPC server,
autoCloseableDevice and the daemon's goroutine scheduling -- and the
failure being investigated was reported through that stack. Flags allow
isolating the upload path, and pointing it at a missing directory to
exercise the LS error path deliberately.

Note it raises the gRPC receive limit: a 4 MiB file plus framing exceeds
the client default of 4MB, so GetFile fails with ResourceExhausted
otherwise. That is a client-side default, not something SNI imposes --
SNI already allows 100MB inbound.

freeze_test.go hunts for a stalled transfer using the production write
path, timing the gap between chunks rather than comparing bytes, and
reports the worst gap even on success so a near miss is visible.

usbstat_test.go and usbstat_probe_test.go read counters written by an
instrumented firmware build, and attribute dropped packets to commands
rather than to bulk data.

putfile_test.go now distinguishes a write that stored the wrong bytes from
a read that returned them wrongly, by reading the file back twice: a
single comparison cannot tell those apart, and they have different causes.

device_test.go wires the SNI_* environment variables into the test binary.
Test binaries never call config.Load(), so those settings were silently
inert and any config-dependent result would have been meaningless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARLkt3NCnrwBMvpP8eAfDR
SNI presents only a status-bar (tray) icon with no window, so macOS is
free to place it under App Nap while a game is in the foreground. App Nap
throttles and coalesces a background app's timers and threads; a napped
SNI stops servicing the FX Pak Pro's USB serial stream promptly, so the
device appears to stop responding (reads return zero bytes, writes stall)
until SNI is restarted. This matches field reports of the connection dying
after some minutes of play on macOS while the identical setup is stable on
Windows, which has no equivalent throttling.

Take a process-level NSProcessInfo activity assertion
(NSActivityUserInitiatedAllowingIdleSystemSleep) for the lifetime of the
process. That keeps SNI out of App Nap while still letting the machine
sleep normally when the user walks away. The assertion is held in a new
cmd/sni/power package behind a build tag; it is a no-op on non-darwin
platforms.

This complements the fxpakpro desync/timeout fixes on this branch: those
let SNI survive and reconnect after the device wedges, while this keeps
the background process from being throttled into that state in the first
place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVRWxcANar3DxSRTsvPPMu
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.

2 participants