Skip to content

oto: fix a uint32 underflow of WASAPI write frames - #297

Merged
hajimehoshi merged 2 commits into
ebitengine:mainfrom
kumagi:oto-wasapi-frames-underflow
Sep 12, 2026
Merged

oto: fix a uint32 underflow of WASAPI write frames#297
hajimehoshi merged 2 commits into
ebitengine:mainfrom
kumagi:oto-wasapi-frames-underflow

Conversation

@kumagi

@kumagi kumagi commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

What issue is this addressing?

No issue filed yet. Found during an audit of the WASAPI driver.

What type of issue is this addressing?

bug

What this PR does | solves

In (*wasapiContext).writeOnRenderThread (driver_wasapi_windows.go), both operands of the free-space calculation are uint32:

bufferFrames     uint32            // c.bufferFrames
paddingFrames    uint32            // returned by IAudioClient2::GetCurrentPadding

frames := c.bufferFrames - paddingFrames
if frames <= 0 { // on uint32 this only catches the exact-zero case
	return nil
}
dstBuf, err := c.renderClient.GetBuffer(frames)

GetCurrentPadding is sampled asynchronously from the audio thread and can transiently report more padding than the buffer size (e.g. right after a device switch or while the event handler is delayed). On uint32, bufferFrames - paddingFrames then wraps around:

4096 - 5000 = 4294966392 (uint32)

The frames <= 0 guard cannot catch this, and GetBuffer is called with ~4 billion frames. WASAPI rejects it with AUDCLNT_E_BUFFER_TOO_LARGE, writeOnRenderThread returns the error, loopOnRenderThread exits, and the whole context dies with a fatal error — instead of simply skipping one write.

Reproduction

The wrap-around itself is plain Go semantics:

var bufferFrames, paddingFrames uint32 = 4096, 5000
frames := bufferFrames - paddingFrames
fmt.Println(frames) // 4294966392 — "frames <= 0" is false

That a padding value larger than the buffer size is possible follows from the API contract: GetCurrentPadding is only documented as "the number of frames of padding" at the moment of the call, with no guarantee it is ≤ the buffer size obtained earlier (GetBufferSize is only read once at initialization), and the two calls are not atomic with the device state.

The fix

Subtract in signed integers so that the existing frames <= 0 guard actually works, and convert at the API boundaries:

frames := int(c.bufferFrames) - int(paddingFrames)
if frames <= 0 {
	return nil
}
dstBuf, err := c.renderClient.GetBuffer(uint32(frames))
...
c.renderClient.ReleaseBuffer(uint32(frames), 0)

This makes the driver skip the write (as it already does for the frames == 0 case) and recover on the next sample-ready event instead of tearing the context down.

bufferFrames and paddingFrames are uint32, so when the reported padding
transiently exceeded the buffer size, bufferFrames - paddingFrames
wrapped around to a huge value. The 'frames <= 0' guard only caught the
exact zero case, and GetBuffer then failed with the wrapped count,
killing the context instead of skipping the write. Subtract in signed
ints and convert at the API boundaries.
@hajimehoshi

Copy link
Copy Markdown
Member

Two suggestions to address in this PR:

  1. Compare the unsigned values before subtracting:

    if paddingFrames >= c.bufferFrames {
        return nil
    }
    frames := c.bufferFrames - paddingFrames

    This expresses the intended guard directly, avoids architecture-dependent int conversions, and leaves the GetBuffer and ReleaseBuffer calls unchanged.

  2. Please frame this as defensive hardening unless there is a reproduction or other evidence that WASAPI actually returns padding greater than buffer capacity. The Go example demonstrates unsigned wraparound, but does not establish the claimed device-switch or delayed-handler trigger. Microsoft's documentation describes padding as queued frames in the endpoint buffer and explicitly recommends subtracting it from the buffer length: https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudioclient-getcurrentpadding

    The source comment should likewise avoid stating that transient excess padding is an established behavior without evidence.

These both concern the change itself and can be handled here.

Comment authored by Codex (OpenAI), on behalf of @hajimehoshi.

@kumagi

kumagi commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Both points are addressed:

  1. The code now compares the unsigned values before subtracting (if paddingFrames >= c.bufferFrames { return nil }) and then does the plain frames := c.bufferFrames - paddingFrames, so the int conversions and the uint32(frames) casts at the GetBuffer/ReleaseBuffer call sites are gone.

  2. Agreed — there is no reproduction or other evidence that WASAPI actually reports padding larger than the buffer size, so this is purely defensive hardening. The comment has been reworded accordingly: it no longer claims transient excess padding as established behavior, and only notes that if such a value were ever reported, the uint32 subtraction would wrap around, so the write is skipped instead.

PTAL.

@hajimehoshi hajimehoshi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@hajimehoshi
hajimehoshi merged commit 3500e07 into ebitengine:main Sep 12, 2026
9 checks passed
@kumagi
kumagi deleted the oto-wasapi-frames-underflow branch September 12, 2026 15:04
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