Allow lone-surrogate keyword keys in f(**d) - #8409
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughKeyword argument keys now use WTF-8 storage and conversion throughout RustPython. Function calls, dictionary unpacking, standard-library forwarding, error formatting, and WASM boundaries preserve or handle surrogate-containing string keys. ChangesWTF-8 keyword argument handling
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant PythonCall
participant FuncArgs
participant KwArgs
participant FunctionBinding
PythonCall->>FuncArgs: pass keyword names
FuncArgs->>KwArgs: store names as Wtf8Buf
KwArgs->>FunctionBinding: provide keyword entries
FunctionBinding->>PythonCall: bind valid UTF-8 names or report unexpected keys
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/wasm/src/convert.rs`:
- Around line 122-127: Preserve lone surrogates at both WASM conversion sites:
in crates/wasm/src/convert.rs lines 122-127, convert incoming JavaScript keyword
names directly from UTF-16 into Wtf8Buf before inserting them into kwargs
instead of routing through String; in lines 235-237, make the
Python-to-JavaScript key conversion explicitly lossless and avoid to_string()
replacement with U+FFFD, using the established Wtf8Buf/UTF-16 conversion
contract.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro Plus
Run ID: 37ecacc2-945c-45a2-ba8e-4939ef17159a
📒 Files selected for processing (13)
crates/capi/src/abstract_.rscrates/stdlib/src/_asyncio.rscrates/vm/src/builtins/function.rscrates/vm/src/frame.rscrates/vm/src/function/argument.rscrates/vm/src/stdlib/_ast/python.rscrates/vm/src/stdlib/_ctypes/structure.rscrates/vm/src/stdlib/_ctypes/union.rscrates/vm/src/stdlib/_functools.rscrates/vm/src/stdlib/_operator.rscrates/vm/src/stdlib/_typing.rscrates/vm/src/types/structseq.rscrates/wasm/src/convert.rs
| py_func_args.kwargs.insert( | ||
| // JS strings coming in are UTF-16; go through Rust `String` | ||
| // (kwargs keys are now WTF-8, so convert String -> Wtf8Buf). | ||
| String::from(js_sys::JsString::from(key)).into(), | ||
| js_to_py(vm, val), | ||
| ); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== repo files around wasm convert =="
git ls-files | rg '(^|/)crates/wasm/src/convert\.rs$|Cargo\.toml$' | sed -n '1,80p'
echo
echo "== convert.rs outline =="
ast-grep outline crates/wasm/src/convert.rs --view expanded 2>/dev/null | sed -n '1,160p'
echo
echo "== relevant snippets =="
cat -n crates/wasm/src/convert.rs | sed -n '1,280p'
echo
echo "== WASM/wtfs/JsString references =="
rg -n "Wtf8Buf|JsString|Reflect::set|to_string|keyword|kwargs|wtf" crates/wasm crates -S | sed -n '1,220p'Repository: RustPython/RustPython
Length of output: 35443
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== crate manifests and workspace config =="
cat -n crates/wasm/Cargo.toml | sed -n '1,220p'
echo
cat -n crates/wtf8/Cargo.toml | sed -n '1,160p'
echo
cat -n crates/common/Cargo.toml | sed -n '1,220p'
echo
sed -n '1,120p' Cargo.toml
echo
echo "== wtf8 API =="
rg -n "pub (struct|impl|fn|trait)|Wtf8Buf|Wtf8|from.*utf|utf.*16|TryFrom|From<JsString>|JsValue" crates/wtf8/src crates -S | sed -n '1,260p'
echo
echo "== Python wtf8/string APIs =="
rg -n "impl \.Into.*Wtf8Buf|Wtf8Buf|as_wtf8|into_wtf8|From<.*Wtf8Buf.*>.*PyStr|PyStr" crates/vm crates/common crates/literal -S | sed -n '1,260p'Repository: RustPython/RustPython
Length of output: 50377
🌐 Web query:
Rust String::from JsString js_sys lone surrogates UTF-16
💡 Result:
When converting a JavaScript string to a Rust String using String::from(js_string) (where js_string is a js_sys::JsString), the conversion is lossy if the JavaScript string contains lone (unpaired) surrogates [1][2]. JavaScript strings are encoded as UTF-16 and can contain unpaired surrogates, whereas Rust strings are strictly required to be valid UTF-8 [2]. During conversion, the wasm-bindgen library utilizes the TextEncoder API to transform the string, which replaces any unpaired surrogates with the Unicode replacement character (U+FFFD,) [2]. Consequently, the resulting Rust string will not be identical to the original JavaScript string [2]. To check if a js_sys::JsString contains lone surrogates before conversion, you can use the is_valid_utf16() method [1][3]. If this method returns false, the string is not valid UTF-16, and String::from will perform a lossy conversion [1][2]. For scenarios where a lossless representation is required, you must avoid converting the string to a Rust String [2]. Instead, you should: 1. Keep the data as a js_sys::JsString handle [2]. 2. Use the js_sys::JsString::iter() method or the char_code_at accessor to manually access the raw UTF-16 code units [1][2]. This allows you to handle the data without triggering the lossy UTF-8 conversion [2].
Citations:
- 1: https://docs.rs/js-sys/latest/js_sys/struct.JsString.html
- 2: https://wasm-bindgen.github.io/wasm-bindgen/reference/types/str.html
- 3: https://wasm-bindgen.github.io/wasm-bindgen/api/js_sys/struct.JsString.html
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Wtf8Buf::from_wide/source =="
cat -n crates/wtf8/src/lib.rs | sed -n '327,372p'
echo
echo "== JsString code unit / UTF-16 docs search in local manifests =="
rg -n "JsString|TextEncoder|char_code_at|iter\\(|is_valid_utf16|from_wide|Wtf8Buf::from" crates wasm -S 2>/dev/null | sed -n '1,220p'
echo
echo "== behavioral model of lone surrogate UTF-16 to Rust String/Wtf8Buf =="
python3 - <<'PY'
import codecs
samples = [
("unpaired lead", 0xD800),
("unpaired trail", 0xDC00),
("surrogate pair", 0x1D306),
]
for name, cp in samples:
bits = cp if cp <= 0xFFFF else 0xD800 | ((cp - 0x10000) >> 10), 0xDC00 | ((cp - 0x10000) & 0x3ff)
units = [x for cp in [cp] for x in ([x] if cp <= 0xFFFF else bits)]
enc_utf8 = codecs.utf_16_encode(bytes().join(i.to_bytes(2, 'big') for i in units))[0] if False else None
try:
rust_like = "".join(chr(i) for i in units).encode("utf-8").decode("utf-8")
print(name, "units", [hex(i) for i in units], "bytes", enc_utf8, "roundtrip", repr(rust_like), "back to units", [ord(c) for c in rust_like])
except UnicodeDecodeError as e:
print(name, "Rust UTF-8 decode fails", e)
else:
pass
PYRepository: RustPython/RustPython
Length of output: 20796
Preserve lone-surrogate JavaScript keyword names through the WASM boundary. String::from(js_sys::JsString::from(key)) loses unpaired surrogates before kwargs key insert, and k.to_string().into() substitutes U+FFFD on round trip; store incoming JS keys via a UTF-16-aware Wtf8Buf conversion, and make the outbound Python→JS boundary contract explicit/lossless as well.
📍 Affects 1 file
crates/wasm/src/convert.rs#L122-L127(this comment)crates/wasm/src/convert.rs#L235-L237
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/wasm/src/convert.rs` around lines 122 - 127, Preserve lone surrogates
at both WASM conversion sites: in crates/wasm/src/convert.rs lines 122-127,
convert incoming JavaScript keyword names directly from UTF-16 into Wtf8Buf
before inserting them into kwargs instead of routing through String; in lines
235-237, make the Python-to-JavaScript key conversion explicitly lossless and
avoid to_string() replacement with U+FFFD, using the established Wtf8Buf/UTF-16
conversion contract.
There was a problem hiding this comment.
py_serde and the other str→JS conversions here also panic on surrogates, so fixing only this site would be inconsistent. I'd handle the whole boundary in a separate issue.
There was a problem hiding this comment.
@HyoJongPark, agreed. Fixing only keyword names would create an inconsistent partial boundary policy while other PyStr → JS conversion paths still fail on lone surrogates. This is better handled comprehensively in a separate follow-up; I won’t treat it as a blocker for this PR.
Would you like me to open the GitHub issue for the broader WASM string/surrogate boundary work?
✏️ Learnings added
Learnt from: HyoJongPark
Repo: RustPython/RustPython PR: 8409
File: crates/wasm/src/convert.rs:122-127
Timestamp: 2026-07-29T01:10:00.591Z
Learning: In `crates/wasm/src/convert.rs`, Python-string-to-JavaScript conversion is not yet surrogate-safe across the whole WASM boundary: `py_serde` and other `PyStr` → JS conversion paths can panic on lone surrogates. Changes to surrogate behavior in the WASM bridge should define and implement a consistent boundary-wide policy rather than fixing only kwargs key conversions.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
repo="RustPython/RustPython"
title="Define surrogate-safe Python↔JavaScript string conversion at the WASM boundary"
body="$(cat <<'EOF'
## Summary
Define and implement one consistent, surrogate-safe conversion policy for Python strings crossing the RustPython WASM JavaScript boundary.
RustPython keyword arguments now retain lone surrogates internally using WTF-8, but converting only WASM keyword names would be incomplete: other `PyStr` → JavaScript paths (including `py_serde`) can still fail or perform lossy conversion when a string contains an unpaired surrogate.
## Rationale
JavaScript strings are UTF-16 and may contain lone surrogate code units. Python `str` values in RustPython can represent those values through WTF-8. Rust `String` and several wasm-bindgen conversion paths are not a lossless representation for this data, so ad hoc conversions can replace lone surrogates with U+FFFD or panic.
The whole WASM bridge needs an explicit policy that applies consistently to both directions and all relevant string conversion paths.
## Affected areas
- `crates/wasm/src/convert.rs`
- JavaScript object/keyword keys converted into Python `FuncArgs`
- Python function keyword keys converted into JavaScript object properties
- general object conversion paths
- Python-string-to-JavaScript serialization via `py_serde` / `serde_wasm_bindgen`
- Any shared WASM helpers used for `PyStr`, WTF-8, `js_sys::JsString`, or JavaScript UTF-16 conversion
## Required changes
1. Establish and document the intended surrogate policy for Python↔JavaScript string conversion at the WASM boundary.
2. Implement shared, UTF-16-aware conversion helpers where needed so JavaScript strings with lone surrogates can enter the Python/WTF-8 representation without loss.
3. Make the Python→JavaScript conversion behavior match the policy across keyword names, object/string conversion, and serialization paths.
4. Remove or avoid isolated conversions that route surrogate-containing data through Rust `String` when that would be lossy or panic.
5. Add WASM-focused regression coverage for lone lead and trailing surrogates in relevant key and value conversion paths.
## Acceptance criteria
- No PyStr→JS conversion path in the WASM bridge panics solely because a Python string contains a lone surrogate.
- The behavior for lone surrogates is consistent and explicitly defined across kwargs, object properties, and serialized string values.
- JavaScript→Python conversion preserves UTF-16 lone surrogates where the boundary contract requires it.
- Tests cover both directions and demonstrate the agreed behavior for paired and unpaired surrogates.
## Backlinks
- PR `#8409`: https://github.com/RustPython/RustPython/pull/8409
- Review discussion: https://github.com/RustPython/RustPython/pull/8409#discussion_r3670087497
- Related issue: `#8228`
Requested by `@HyoJongPark`.
EOF
)"
gh issue create \
--repo "$repo" \
--title "$title" \
--body "$body" \
--assignee "HyoJongPark"Length of output: 3207
@HyoJongPark, created and assigned the follow-up issue: #8410 — Define surrogate-safe Python↔JavaScript string conversion at the WASM boundary.
It links back to #8409 and this review discussion, and covers the boundary-wide policy and regression-test requirements. 🐇
🧠 Learnings used
Learnt from: HyoJongPark
Repo: RustPython/RustPython PR: 8409
File: crates/wasm/src/convert.rs:122-127
Timestamp: 2026-07-29T01:10:00.591Z
Learning: In the WASM bridge, ensure Python string (PyStr / py_serde) to JavaScript conversion is surrogate-safe across the full boundary. Lone Unicode surrogates must not cause panics in any PyStr → JS conversion path; instead of fixing behavior for only specific cases (e.g., kwargs key conversions), define and implement a single, consistent boundary-wide surrogate policy in the WASM string conversion layer.
`f(**{'\udc81': 2})` raised `TypeError: keywords must be strings` even
though the key is a valid `str`, because `KwArgs` stored keys as Rust
`String` (strict UTF-8) and `collect_ex_args` narrowed each key to
`PyUtf8Str` (valid UTF-8 required). CPython only checks that the key is a
`str` (`Py_TPFLAGS_UNICODE_SUBCLASS`), never encoding validity.
Change `KwArgs<T>`'s key type from `String` to `Wtf8Buf` (the WTF-8
storage `PyStr` already uses) and relax the keyword-key downcast from
`PyUtf8Str` to `PyStr` in `collect_ex_args`, `from_vectorcall`/
`from_vectorcall_owned` (which previously panicked on surrogate keys),
the `functools.partial` keyword merge, and the C-API `dict_to_kwargs`.
`Wtf8Buf` borrows only as `Wtf8`, so inherent `get`/`contains_key`/
`swap_remove`/`shift_remove(&str)` on `KwArgs` restore the `&str` lookup
interface `String: Borrow<str>` used to provide (via the zero-cost
`Wtf8::new` cast), and a generic `FromIterator<(K: Into<Wtf8Buf>, T)>`
keeps construction sites unchanged. WTF-8 awareness stays localized to
`function/argument.rs`.
Fixes RustPython#8228
Assisted-by: Claude Code:claude-opus-4-8
62bcc01 to
daf72d7
Compare
|
|
||
| for (key, _value) in &args.kwargs { | ||
| let key_obj: PyObjectRef = vm.ctx.new_str(key.as_str()).into(); | ||
| let key_obj: PyObjectRef = vm.ctx.new_str(key.clone()).into(); |
There was a problem hiding this comment.
there must be a way not to use clone here. could you check? I will find out one if there isn't any way to do it yet.
There was a problem hiding this comment.
for (key, _value) in &args.kwargs {
- let key_obj: PyObjectRef = vm.ctx.new_str(key.clone()).into();
+ let key_obj: PyObjectRef = vm.ctx.new_str(key.as_ref()).into();Replaced the clone with as_ref() to avoid an unnecessary copy.
new_str only needs a &Wtf8, so instead of cloning the key into an owned Wtf8Buf, I pass key.as_ref() to borrow it instead.
Does this match what you had in mind?
| } | ||
| for (key, value) in args.kwargs { | ||
| let key_obj: PyObjectRef = vm.ctx.new_str(key.as_str()).into(); | ||
| let key_obj: PyObjectRef = vm.ctx.new_str(key.clone()).into(); |
`new_str` only needs a `&Wtf8`, so pass `key.as_ref()` rather than cloning the key into an owned `Wtf8Buf`. The key is reused afterwards (error message, `intern_str`), so a borrow is the right fit. Assisted-by: Claude Code:claude-opus-4-8
f(**d)rejects str keys containing lone surrogates: TypeError: keywords must be strings #8228Summary
Unpacking a dict via
**raisedTypeError: keywords must be stringswhen a key contained a lone surrogate, even though the key is a validstr. CPython accepts anystrkey on the**call path, so RustPython should behave the same.collect(**{'\udc81': 2}):{'\udc81': 2}TypeError: keywords must be strings{'\udc81': 2}RustPython stores kwargs keys as Rust
String, which holds only UTF-8 and cannot represent surrogates.collect_ex_argsrejects a surrogate key when it downcasts it toPyUtf8Str(a str guaranteed to be valid UTF-8).Relaxing only that check isn't enough: putting a surrogate key into a
Stringthen panics inas_str(), so the storage type itself must change to one that can hold surrogates.Changes
Changed
KwArgs's key type fromStringtoWtf8Buf. (vm/function/argument.rs:417)Wtf8Bufis the WTF-8 storagePyStralready uses internally, so it holds surrogates while reusing the existing hashing/equality logic.PyUtf8StrtoPyStr, so a value only needs to be astr(as in CPython).collect_ex_args—vm/frame.rs:7118from_vectorcall/from_vectorcall_owned—vm/function/argument.rs:162,:193functools.partialmerge —vm/stdlib/_functools.rs:441dict_to_kwargs—capi/abstract_.rs:31get/contains_key/swap_remove/shift_remove(&str)toKwArgs. (vm/function/argument.rs:457)Wtf8Bufcan't be looked up by&strdirectly, so these keep existing lookup call sites compiling unchanged.FromIteratorto acceptInto<Wtf8Buf>, keeping existing code that builds kwargs fromStringliterals. (vm/function/argument.rs:480)_ctypes,_operator,_asyncio,structseq, etc.) to the new key type (String→Wtf8Buf); behavior is unchanged.wasm/src/convert.rs) that pass kwargs to/from JS.Test
collect(**{'\udc81': 2})→{'\udc81': 2}dict(**{'\udc81': 1})→{'\udc81': 1}functools.partial(lambda **kw: kw, **{'\udc81': 1})()→{'\udc81': 1}cargo run --release -- -m test test_extcall test_call test_functools test_typing test_ctypes test_asyncio test_ast→Tests result: SUCCESS(no unexpected successes).rustpython-vmunit tests pass.cargo fmt/cargo clippyclean.Summary by CodeRabbit
functools.partial._ast,ctypes(structures/unions),structseq,operator.methodcaller, and WebAssembly interop.