Skip to content

Translate NTSTATUS into the last-error slot instead of discarding it - #13

Open
Borega wants to merge 2 commits into
Zvendson:mainfrom
Borega:fix/ntstatus-lasterror
Open

Translate NTSTATUS into the last-error slot instead of discarding it#13
Borega wants to merge 2 commits into
Zvendson:mainfrom
Borega:fix/ntstatus-lasterror

Conversation

@Borega

@Borega Borega commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Split out of #4 at @Zvendson's request — this is the actual root cause he asked for:

The main issue is, that we would actually need to properly handle every win32 function call here. Instead of appending every Win32Exception (which can be indeed none), we should figure out which edge case is reporting a 0 win error that causes a successful call with a wrong result

The edge case is NtUnmapViewOfSection in SharedMemory.destroy().

Why

NTSTATUS routines report failure in their return value and never call SetLastError. The five wrappers in windows.py collapsed that to a bool:

nt_status: int = _NtUnmapViewOfSection(process_handle, base_address)
return nt_status == STATUS_SUCCESS

The NTSTATUS is the only copy of the error, and it is dropped on the floor. A Win32Exception() raised afterwards reads whatever the previous call left in the thread-local slot. Measured on Win11 x64, 32-bit CPython:

NtUnmapViewOfSection(bad addr):        NTSTATUS=0xC0000019  GetLastError=0
after SetLastError(1234), same call:   NTSTATUS=0xC0000019  GetLastError=1234   <- stale
CloseHandle(pseudo) succeeds:                               GetLastError=1234   <- success never clears

destroy() runs UnmapViewOfFileCloseHandleNtUnmapViewOfSectionDuplicateHandle, appending a Win32Exception() after each. So:

  • steps 1–2 succeeded, step 3 fails → slot is 0 → FormatMessageW(0)"The operation completed successfully." This is the "0 win error" from your comment.
  • steps 1–2 failed → step 3 reports their code. Wrong but plausible, and harder to notice than the success message.

Either way STATUS_NOT_MAPPED_VIEW — the real failure — is gone.

Fix

ntdll ships RtlNtStatusToDosError for exactly this:

0xC0000019 -> 487  Attempt to access invalid address.
0xC0000008 ->   6  The handle is invalid.
0xC000000D ->  87  The parameter is incorrect.

_nt_ok() translates and publishes, so the failure survives to the call site regardless of what ran before. Success leaves the slot untouched, matching how a successful Win32 call behaves. Applied to all five NTSTATUS wrappers: NtMapViewOfSection, NtUnmapViewOfSection, NtQueryInformationProcess, NtSuspendProcess, NtResumeProcess.

SetLastError and RtlNtStatusToDosError are exported alongside the existing GetLastError.

Tests

tests/test_ntstatus_last_error.py (5 tests): the translation table, a real NtUnmapViewOfSection failure from both a clean (0) and a poisoned (1234) last-error state, and that success does not clobber the slot. Three of the five fail without this change — verified by reverting _nt_ok's body:

FAILED test_nt_ok_publishes_the_translated_failure
FAILED test_failure_is_reported_even_from_a_clean_last_error
FAILED test_failure_is_not_masked_by_a_stale_last_error
assert 1234 == 487

Full suite: 182 passed, 2 skipped.

Relation to #4

With this merged, #4's zero-guard stops being the fix and becomes a cheap backstop for any call site still doing it wrong. I'll rebase #4 onto this and reframe it as Win32Exception.args/__reduce__ + SharedMemoryCleanupError, which are independent of the root cause.


One thing I deliberately left out, happy to do it separately if you want it: every wrapper uses windll rather than WinDLL(..., use_last_error=True), so GetLastError() is a second FFI call and the CPython runtime can clobber the slot in between. ctypes.get_last_error() closes that hole, but it touches all ~70 declarations — too big to smuggle in here.

NTSTATUS routines report failure in their return value and never call
SetLastError. The five wrappers here collapsed that to a bool, which threw
away the only copy of the error:

    nt_status = _NtUnmapViewOfSection(process_handle, base_address)
    return nt_status == STATUS_SUCCESS

A Win32Exception() raised afterwards then reads whatever the *previous* call
left in the thread-local slot. Measured:

    NtUnmapViewOfSection(bad addr):        NTSTATUS=0xC0000019  GetLastError=0
    after SetLastError(1234), same call:   NTSTATUS=0xC0000019  GetLastError=1234
    CloseHandle(pseudo) succeeds:                               GetLastError=1234

Both outcomes are wrong, in different ways. SharedMemory.destroy() shows it:
UnmapViewOfFile and CloseHandle run before NtUnmapViewOfSection, so if they
succeed on a fresh thread the slot is 0 and FormatMessageW(0) yields "The
operation completed successfully." inside a raised exception. If they failed,
step three reports *their* code -- wrong but plausible, and harder to spot.

_nt_ok() translates via RtlNtStatusToDosError and publishes the result, so
0xC0000019 surfaces as 487 "Attempt to access invalid address." regardless of
what ran before. Success leaves the slot untouched, matching how a successful
Win32 call behaves.

Also exports SetLastError and RtlNtStatusToDosError, which the module needed
internally and callers can reasonably want.

tests/test_ntstatus_last_error.py covers the translation table, both last-error
states around a real NtUnmapViewOfSection failure, and that success does not
clobber the slot. Three of the five fail without this change.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes incorrect post-failure error reporting for NTSTATUS-returning ntdll calls by translating NTSTATUS failures into the thread’s Win32 last-error slot, so downstream Win32Exception() instances consistently reflect the real failure instead of GetLastError()==0 or stale values.

Changes:

  • Add SetLastError, RtlNtStatusToDosError, and _nt_ok() to translate/publish NTSTATUS failures into GetLastError().
  • Update the five NTSTATUS wrappers (NtMapViewOfSection, NtUnmapViewOfSection, NtQueryInformationProcess, NtSuspendProcess, NtResumeProcess) to use _nt_ok().
  • Add regression tests covering translation correctness and last-error behavior across clean/poisoned states.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
MemLib/windows.py Adds NTSTATUS→Win32 translation/publishing helpers and routes NTSTATUS wrappers through _nt_ok() so failures populate the last-error slot.
tests/test_ntstatus_last_error.py Adds regression tests ensuring NTSTATUS failures are translated into GetLastError() and success does not clobber the slot.
Suppressed comments (1)

tests/test_ntstatus_last_error.py:41

  • After renaming the NTSTATUS constant at the top of the file, this test should use the updated name for consistency.
def test_nt_ok_publishes_the_translated_failure():
    SetLastError(0)

    assert _nt_ok(STATUS_CONFLICTING_ADDRESSES) is False
    assert GetLastError() == ERROR_INVALID_ADDRESS

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_ntstatus_last_error.py Outdated
Comment on lines +16 to +26
# STATUS_CONFLICTING_ADDRESSES: what NtUnmapViewOfSection returns for an address that is
# not the base of a mapped view. Translates to ERROR_INVALID_ADDRESS (487).
STATUS_CONFLICTING_ADDRESSES: int = 0xC0000019
ERROR_INVALID_ADDRESS: int = 487
UNMAPPED_ADDRESS: int = 0xDEAD0000


def test_status_translates_to_the_matching_win32_code():
assert RtlNtStatusToDosError(STATUS_CONFLICTING_ADDRESSES) == ERROR_INVALID_ADDRESS
assert RtlNtStatusToDosError(0xC0000008) == 6 # STATUS_INVALID_HANDLE -> ERROR_INVALID_HANDLE
assert RtlNtStatusToDosError(0xC000000D) == 87 # STATUS_INVALID_PARAMETER -> ERROR_INVALID_PARAMETER

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correct, and thanks -- fixed in 66456d5. ntstatus.h confirms it:

#define STATUS_CONFLICTING_ADDRESSES     ((NTSTATUS)0xC0000018L)
#define STATUS_NOT_MAPPED_VIEW           ((NTSTATUS)0xC0000019L)

NtUnmapViewOfSection(0xDEAD0000) does return 0xC0000019, so the value was right and only the name was wrong. Both statuses happen to translate to ERROR_INVALID_ADDRESS (487), so the test passed either way -- which is exactly why the wrong name was worth fixing rather than shrugging at: it would have misled the next reader about which failure is being asserted, with nothing failing to correct them.

Renamed the constant to STATUS_NOT_MAPPED_VIEW and dropped the stale comment. Same mistake was in my commit message and PR body; the PR body is now corrected.

Comment thread MemLib/windows.py
Comment on lines +206 to +210
if nt_status == STATUS_SUCCESS:
return True

_SetLastError(_RtlNtStatusToDosError(nt_status))
return False

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed -- fixed in 66456d5. _nt_ok now calls the public SetLastError/RtlNtStatusToDosError.

The module convention already backs this up: Win32Exception.__init__ calls the public GetLastError(), and _GetLastError() is referenced exactly once, inside its own wrapper. My use of the private bindings was the outlier.

Your point about ctypes.get_last_error() is the one that decided it -- that swap would otherwise need touching every internal call site instead of just the two wrappers. Which is also why I left that swap out of this PR: all ~70 declarations use windll rather than WinDLL(..., use_last_error=True), so GetLastError() is a second FFI call and the CPython runtime can clobber the slot in between. Real hole, but too big to smuggle in here -- separate PR if @Zvendson wants it.

0xC0000019 is STATUS_NOT_MAPPED_VIEW, not STATUS_CONFLICTING_ADDRESSES
(0xC0000018) -- confirmed against ntstatus.h. Both translate to
ERROR_INVALID_ADDRESS (487), so the test passed either way and the wrong name
would have quietly misled the next reader about which failure is asserted.

_nt_ok() now calls the public SetLastError/RtlNtStatusToDosError rather than
the private bindings, matching how Win32Exception already uses GetLastError(),
and keeping a future swap to ctypes.get_last_error() local to the wrappers.

Full suite: 182 passed, 2 skipped. The same three tests still fail without
_nt_ok's body.
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