Stop Win32Exception from reporting success, and keep cleanup failures inspectable - #4
Stop Win32Exception from reporting success, and keep cleanup failures inspectable#4Borega wants to merge 2 commits into
Conversation
… inspectable
Win32Exception snapshots GetLastError() at construction. MemLib constructs it
wherever an API returns falsy, without checking that an error was actually
set, so when GetLastError() is 0 FormatMessageW produces:
Win32Exception: The operation completed successfully. (0x00000000)
...inside a raised exception. Downstream code cannot treat that as either
success or failure, and ends up string-matching the message to survive
teardown.
Changes:
* `__format_message` no longer calls FormatMessageW for code 0. It reports
that the call failed without setting an error code, which is what actually
happened.
* `RuntimeError.args` is now populated (it was empty), so logging, `repr()`,
and re-raise paths that inspect `args` see the message and code.
* Added `__reduce__` so pickling round-trips the captured code instead of
re-reading the by-then-unrelated thread-local last error.
`SharedMemory.destroy()` and `close_shared_memory_connection()` raised a bare
`Exception` with the individual failures flattened into a formatted string,
so callers could only grep it. Both now raise `SharedMemoryCleanupError`,
which keeps the `list[Win32Exception]` on `.errors` (plus `.codes`) and lets
callers branch on a specific code, e.g. ignoring ERROR_ACCESS_DENIED from an
already-dead target. It subclasses `Exception`, so existing
`except Exception` handlers are unaffected.
Adds tests/test_win32_exception.py (11 tests). Full suite: 85 passed,
2 skipped.
There was a problem hiding this comment.
🟡 Not ready to approve
The new tests hard-code localized Windows error text and use a potentially non-deterministic handle value, which can make CI and user test runs flaky across environments.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR improves error reporting and robustness around Windows API failures by making Win32Exception accurately represent “no error set” scenarios, and by preserving structured cleanup failure details for shared-memory teardown so callers can branch on error codes instead of grepping strings.
Changes:
- Special-case
Win32Exceptionerror code0to avoid misleading “completed successfully” messaging; also populateargsand add__reduce__to preserve the captured error across pickling. - Introduce
SharedMemoryCleanupErrorto raise aggregated cleanup failures while keeping individualWin32Exceptioninstances and codes inspectable. - Add focused tests for the new behaviors and export
SharedMemoryCleanupErrorfrom the package API.
File summaries
| File | Description |
|---|---|
| tests/test_win32_exception.py | Adds tests covering the new Win32Exception formatting/pickling behavior and the new cleanup aggregation exception. |
| MemLib/windows.py | Updates Win32Exception to avoid “success” messages on code 0 and to preserve captured state via args and pickling support. |
| MemLib/SharedMemory.py | Adds SharedMemoryCleanupError and switches shared-memory cleanup paths to raise it with structured errors. |
| MemLib/init.py | Exports SharedMemoryCleanupError as part of the public package surface. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| def test_real_error_code_still_formats_the_windows_message(): | ||
| error = Win32Exception(5) # ERROR_ACCESS_DENIED | ||
|
|
||
| assert error.code == 5 | ||
| assert "Access is denied" in error.message |
There was a problem hiding this comment.
Fixed in c247f48. Now asserts ctypes.FormatError(5).strip() in error.message instead of the English string.
| # 0x1 is never a valid handle or mapped view: both cleanup steps fail. | ||
| with pytest.raises(SharedMemoryCleanupError) as caught: | ||
| close_shared_memory_connection(handle=0x1, base_addr=0x1) |
There was a problem hiding this comment.
Not applying this one -- the suggested -1 would break the test. -1 is the pseudo-handle for "current process", and CloseHandle(-1) succeeds:
CloseHandle(1) -> ret=0 err=6 (ERROR_INVALID_HANDLE)
CloseHandle(-1) -> ret=1 (success)
The test needs both cleanup steps to fail so it can exercise the aggregation path, so 0x1 is the correct value. Kept it and added a comment in c247f48 recording why, along with what UnmapViewOfFile(0x1) returns (ERROR_INVALID_ADDRESS), so nobody "fixes" it to -1 later.
Review feedback: FormatMessageW is localized, so asserting on "Access is denied" failed on non-English Windows even when the library was correct. Compare against ctypes.FormatError(5) instead. Kept handle=0x1 in the cleanup test and documented why: the suggested pseudo-handle -1 means "current process", so CloseHandle(-1) returns success (verified: ret=1) and the test would no longer exercise the aggregation path. 0x1 fails with ERROR_INVALID_HANDLE, which is what the test needs.
| fmt_error: list[str] = [f'[Error {i + 1}] -> ' + str(error) for i, error in enumerate(errors)] | ||
| raise Exception(f'Caught {len(errors)} Win32Exception:\n' + '\n-> '.join(fmt_error)) | ||
| raise SharedMemoryCleanupError(errors) |
There was a problem hiding this comment.
I understand the intention and I know my code here is far from perfect. This is still not a real fix to the main issue. 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
|
You're right, and I found the specific call: NTSTATUS routines report failure in their return value and never call In So the aggregation in this PR is exactly what you called it: not a fix. Fixed properly in #13, via What I'd like to do with this PR: rebase it onto #13 and drop the framing that the zero-guard is the fix — with #13 merged it's just a backstop for any call site still doing it wrong. What remains is independent of the root cause:
Happy to drop the zero-guard entirely if you'd rather not have a backstop there — it's genuinely dead code once #13 lands, unless a future call site regresses. Your call. |
Win32Exceptioncan say the operation succeededWin32ExceptionsnapshotsGetLastError()at construction, and MemLib constructs it wherever an API returns falsy — without checking that an error was actually set. WhenGetLastError()is 0,FormatMessageWcheerfully produces:A raised exception claiming success is unactionable: the caller cannot tell whether the operation worked. In practice consumers end up string-matching the message to survive teardown, which is what prompted this PR.
__format_messagenow special-cases code 0 and states what actually happened:Real error codes are unaffected —
Win32Exception(5)still formats "Access is denied."Two related robustness fixes in the same class:
argswas empty.RuntimeError.__init__was never called, soerror.args == (). Logging handlers,repr(), and re-raise paths that inspectargssaw nothing. Now(message, code).__reduce__, so a pickled exception round-trips the captured code rather than whatever the thread-local last error happens to be at unpickle time.SharedMemorycleanup errors could only be greppedSharedMemory.destroy()andclose_shared_memory_connection()collected failures into alist[Win32Exception], then flattened them into a string and raised a bareException:The structured information was discarded at the raise site, so a caller wanting to ignore, say,
ERROR_ACCESS_DENIEDfrom an already-dead target had no option but substring matching. Both now raiseSharedMemoryCleanupError, which keeps the failures on.errorsand exposes.codes:The formatted message is byte-identical to before, and it subclasses
Exception, so existingexcept Exceptionhandlers keep working. Exported fromMemLib/__init__.py.Compatibility
Additive apart from the message for code 0, which was misinformation. Full suite passes (85 passed, 2 skipped) including 11 new tests in
tests/test_win32_exception.py.