|
| 1 | +### Fixed |
| 2 | + |
| 3 | +- **`path.*` read an SSO string's inline bytes as a `StringHeader` pointer (#7621).** |
| 4 | + `path.resolve("/root", computedShortString)` threw |
| 5 | + `TypeError [ERR_INVALID_ARG_TYPE]` where node returns the path, while the same |
| 6 | + call with a *literal* segment worked. |
| 7 | + |
| 8 | + Every `path.*` codegen arm unboxed its operand with `unbox_to_i64` — `bitcast |
| 9 | + double -> i64; and POINTER_MASK` — and handed the low 48 bits to a runtime |
| 10 | + entry that dereferences them as `*const StringHeader`. Those bits are the |
| 11 | + header for a **heap** string (`STRING_TAG` = 0x7FFF) and the **characters** for |
| 12 | + a small-string-optimized one (`SHORT_STRING_TAG` = 0x7FF9, length + up to |
| 13 | + `SHORT_STRING_MAX_LEN` = 5 inline bytes). A literal is interned onto the heap; |
| 14 | + a computed short string takes the inline form. The #214 class, bisected by |
| 15 | + length — 5 bytes threw, 6 bytes worked. |
| 16 | + |
| 17 | + Twelve arms were affected, not the five the issue named — `resolve(a, b)`, |
| 18 | + `resolve(p)`, `join`, `win32.join`, `normalize`, `extname`, `dirname`, |
| 19 | + `basename`, `basename(p, ext)`, `isAbsolute`, `parse` and `matchesGlob`, plus |
| 20 | + the `path.win32.*` equivalents. `parse` and `matchesGlob` were **silently |
| 21 | + wrong** rather than throwing (`path.parse(short).base` was `""`; |
| 22 | + `matchesGlob` matched the pattern against `""`), which is why they had never |
| 23 | + been reported. `path.relative`, `path.format` and `path.toNamespacedPath` |
| 24 | + already took NaN-boxed values (`js_path_relative_checked`, #2995) and were |
| 25 | + unaffected — that is the precedent this change generalises. |
| 26 | + |
| 27 | + Single-operand arms now call `js_path_arg_header`, which materialises **only** |
| 28 | + the SSO case and reproduces the old mask bit for bit for heap strings and for |
| 29 | + every non-string, so each entry point keeps its own established non-string |
| 30 | + behaviour (throw, or `unwrap_or_default`) unchanged. It is deliberately not |
| 31 | + `js_get_string_pointer_unified`, which coerces numbers to strings and would |
| 32 | + have turned `path.isAbsolute(5)` from Node's throw into `false`. |
| 33 | + |
| 34 | + Two-operand arms hand both operands to the runtime NaN-boxed |
| 35 | + (`js_path_*_value` in `crates/perry-runtime/src/path/value_args.rs`). Codegen |
| 36 | + cannot close that window itself: materialising the first operand allocates, and |
| 37 | + `rooting::with_operands_rooted` yields registers rather than slots, so the |
| 38 | + second operand's register is stale the instant the first is materialised and |
| 39 | + there is no re-read to reach for. The runtime entry roots the first operand in |
| 40 | + a `RuntimeHandleScope` and re-reads it through `RuntimeHandle::across_const`. |
| 41 | + |
| 42 | + That rooting half is defensive rather than instrument-proven, and the module |
| 43 | + says so at the site: reverting `across_const` to a pre-bound address produced |
| 44 | + zero faults under `PERRY_GC_ZEAL=1` + `PERRY_GC_PROTECT_FROMSPACE=1` (402k |
| 45 | + copying minors) and 0/400k mismatches under `PERRY_GC_FORCE_EVACUATE=1`, |
| 46 | + because a collection reached from inside an allocation runs with |
| 47 | + `GC_FLAG_IN_ALLOC` set and the copying minor is therefore ineligible — nothing |
| 48 | + moves at an allocation point today. |
| 49 | + |
| 50 | + Covered by `test-files/test_gap_7621_path_sso_operands.ts` (both sides of the |
| 51 | + SSO boundary, computed and literal operands, absolute and relative bases, |
| 52 | + multi-segment resolves, all twelve arms, the non-string throw) plus five |
| 53 | + `perry-runtime` unit tests. |
0 commit comments