Skip to content

Fix UnicodeEncodeError for non-ASCII file paths (#1086) - #1087

Merged
emeryberger merged 3 commits into
masterfrom
fix-1086-non-ascii-paths
Jul 31, 2026
Merged

Fix UnicodeEncodeError for non-ASCII file paths (#1086)#1087
emeryberger merged 3 commits into
masterfrom
fix-1086-non-ascii-paths

Conversation

@emeryberger

Copy link
Copy Markdown
Member

Fixes #1086.

Root cause

pywhere.cpp converted code->co_filename with PyUnicode_AsASCIIString on three paths: the stack walker used by the allocator interposer, on_stack(), and the settrace line callback. For any path containing a non-ASCII character — e.g. a module at .../überschüsse.py — that call fails, returning NULL and leaving a UnicodeEncodeError set on the thread.

Since these run from the native allocator hook and from trace callbacks, the stray exception surfaced later at an arbitrary, unrelated Python line — which is why the reporter saw it land in sysconfig.get_path on one run and inside pydantic on the next, and why the failure looked flaky. Two of the sites also fed the NULL straight into PyBytes_AsString/strstr, giving the reported SIGSEGV.

The Python side compounded it: ScaleneMapFile.get_str() decoded the native sample records as ASCII, so any allocation attributed to such a file also blew up the sample-draining loop.

Only --memory runs were affected, matching the reporter's observation that --cpu-only worked — that's the mode that loads the native interposer.

Changes

  • src/source/pywhere.cpp — new encodeFilename() helper encoding as UTF-8 with surrogateescape. That's the exact inverse of how CPython decodes filesystem paths, so every path Python can represent — including undecodable bytes, which appear as lone surrogates — round-trips. It clears any exception on failure and returns nullptr; all three call sites now NULL-check. Also removes a dead encode-and-discard in trace_func.
  • src/include/traceconfig.hpp — the package-path encode used "strict" and leaked a pending exception on failure; now surrogateescape + PyErr_Clear().
  • scalene/scalene_mapfile.py — new decode_sample() using the matching UTF-8/surrogateescape pair, replacing the ASCII decodes.

Test

tests/test_issue1086_non_ascii_paths.py is picked up by the existing python3 -m pytest step in tests.yml, so it runs on Ubuntu + macOS across Python 3.9–3.14. Three cases:

  1. decode_sample directly — deterministic, including lone surrogates.
      1. End-to-end scalene run --memory on a program importing a non-ASCII module, with the non-ASCII component in the filename and in a parent directory. Asserts no Unicode error, the program ran to completion, exit code 0, and the path comes back through the profile intact (NFC-normalized comparison so an NFD-normalizing filesystem doesn't false-fail).

The crash assertions are hard on every attempt; only the "file appears in the profile" check retries against the usual sampling flake.

Verification

  • Reproduced the issue on macOS / Python 3.14: 3/3 runs died with 'ascii' codec can't encode character '\xfc', same traceback shape as the report.
  • With the fix, the profile is complete (überschüsse.py: 20 MB malloc, 89% CPU); --memory --stacks also carries the non-ASCII path correctly through the native stack buffer.
  • Reverted only the native fix and rebuilt → the two end-to-end tests fail as intended; restored → pass.
  • Full tests/ suite: 445 passed, 13 skipped. ruff clean; mypy shows only the pre-existing scalene_signal_manager.py error also present on master.

🤖 Generated with Claude Code

emeryberger and others added 2 commits July 31, 2026 10:38
pywhere.cpp converted code->co_filename with PyUnicode_AsASCIIString on
three paths: the stack walker used by the allocator interposer,
on_stack(), and the settrace line callback. For any path containing a
non-ASCII character -- e.g. a module at .../überschüsse.py -- that call
fails, returning NULL *and leaving a UnicodeEncodeError set on the
thread*. Since these run from the native allocator hook and from trace
callbacks, the stray exception surfaced later at an arbitrary, unrelated
Python line, which is why the reporter saw it land in sysconfig.get_path
on one run and inside pydantic on the next. Two of the sites also fed the
NULL straight into PyBytes_AsString/strstr, giving the reported SIGSEGV.

Replace all three with a new encodeFilename() helper that encodes as
UTF-8 with surrogate escapes -- the exact inverse of how CPython decodes
filesystem paths, so every path Python can represent (including
undecodable bytes, which appear as lone surrogates) round-trips. It
clears any exception on failure and returns nullptr; every call site now
NULL-checks. Also drops a dead encode-and-discard in trace_func, and
fixes the same pending-exception leak in TraceConfig's package-path
encode, which used "strict".

The Python side compounded the problem by decoding native sample records
as ASCII: add decode_sample() in scalene_mapfile.py using the matching
UTF-8/surrogateescape pair.

Verified by reproducing the crash (3/3 runs) on the unfixed build, then
confirming a complete profile with the fix, on both --memory and
--memory --stacks.

tests/test_issue1086_non_ascii_paths.py is picked up by the existing
pytest step in tests.yml, so it runs on Ubuntu + macOS across Python
3.9-3.14. It covers decode_sample directly (deterministic, including
lone surrogates) and profiles a program importing a non-ASCII module
end to end, with the non-ASCII component both in the filename and in a
parent directory. The crash assertions are hard on every attempt; only
the "file appears in the profile" check retries against sampling flake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reporter's case was "a module in its import tree which is at a path
with non-ascii characters in it" -- the umlaut need not be in the
filename at all, since the native code encoded the whole path. The
previous parametrization only varied whether a non-ASCII *parent* was
added on top of an already non-ASCII filename, so it never isolated
that spelling.

Replace it with three explicit layouts: leaf-only (ASCII directories),
directory-only (ASCII module and immediate parent, umlaut solely in an
interior directory: optionale_verlängerung/pakete/workload.py), and
both. Extend the deterministic decode_sample test the same way.

Verified the new directory-only case against the parent commit's native
code: it fails there with "'ascii' codec can't encode character '\xe4'",
the ä from the interior directory, on an all-ASCII workload.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@emeryberger

Copy link
Copy Markdown
Member Author

Added a directory-only layout to the end-to-end test (90cabf7), covering the reporter's exact framing — "a module in its import tree which is at a path with non-ascii characters in it," where the umlaut is not in the filename at all.

The three layouts are now explicit:

id path non-ASCII component
leaf-only program/überschüsse.py filename
directory-only optionale_verlängerung/pakete/workload.py interior directory only — module and its immediate parent are ASCII
both prögramm/überschüsse.py both

The native code encoded the whole path, so a single non-ASCII character anywhere in it was enough. Verified directory-only against the parent commit's native code:

'ascii' codec can't encode character '\xe4' in position 130: ordinal not in range(128)
  File ".../optionale_verlängerung/pakete/workload.py", line 6, in f

That \xe4 is the ä from the directory, on an all-ASCII workload.py. All four tests pass with the fix.

Picks up the get_ipython() Optional guard (#1088) so this branch's
linters check runs against a fixed master.
@emeryberger
emeryberger merged commit 57aead6 into master Jul 31, 2026
38 of 41 checks passed
@emeryberger
emeryberger deleted the fix-1086-non-ascii-paths branch July 31, 2026 17:04
emeryberger added a commit that referenced this pull request Jul 31, 2026
Brings in the non-ASCII path fix (#1087), the get_ipython() Optional
guard (#1088), and the free-threaded CI setup fix (#1089).
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.

UnicodeEncodeError when non-ASCII characters are in any imported file path

1 participant