Translate NTSTATUS into the last-error slot instead of discarding it - #13
Translate NTSTATUS into the last-error slot instead of discarding it#13Borega wants to merge 2 commits into
Conversation
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.
There was a problem hiding this comment.
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 intoGetLastError(). - 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.
| # 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 |
There was a problem hiding this comment.
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.
| if nt_status == STATUS_SUCCESS: | ||
| return True | ||
|
|
||
| _SetLastError(_RtlNtStatusToDosError(nt_status)) | ||
| return False |
There was a problem hiding this comment.
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.
Split out of #4 at @Zvendson's request — this is the actual root cause he asked for:
The edge case is
NtUnmapViewOfSectioninSharedMemory.destroy().Why
NTSTATUS routines report failure in their return value and never call
SetLastError. The five wrappers inwindows.pycollapsed that to a bool: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:destroy()runsUnmapViewOfFile→CloseHandle→NtUnmapViewOfSection→DuplicateHandle, appending aWin32Exception()after each. So:FormatMessageW(0)→ "The operation completed successfully." This is the "0 win error" from your comment.Either way
STATUS_NOT_MAPPED_VIEW— the real failure — is gone.Fix
ntdllshipsRtlNtStatusToDosErrorfor exactly this:_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.SetLastErrorandRtlNtStatusToDosErrorare exported alongside the existingGetLastError.Tests
tests/test_ntstatus_last_error.py(5 tests): the translation table, a realNtUnmapViewOfSectionfailure 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: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
windllrather thanWinDLL(..., use_last_error=True), soGetLastError()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.