Skip to content

Reject a byte position the lexer cannot start on - #3083

Merged
soutaro merged 1 commit into
ruby:masterfrom
Shopify:reject-unreachable-start-pos
Aug 12, 2026
Merged

Reject a byte position the lexer cannot start on#3083
soutaro merged 1 commit into
ruby:masterfrom
Shopify:reject-unreachable-start-pos

Conversation

@soutaro

@soutaro soutaro commented Aug 12, 2026

Copy link
Copy Markdown
Member

rbs_lexer_new reaches start_pos by stepping one character at a time from the beginning of the buffer, so the only positions it can start lexing from are the first bytes of characters. A byte_range can hand it two other kinds of position:

Inside a character. The step walks over start_pos and lexing quietly begins at the next character boundary. Nothing downstream can tell: line, column and char_pos all stay consistent — they just describe a position the caller did not ask for. Today the shift often surfaces as a syntax error by accident, because a non-ASCII character cannot open a token; but an ASCII letter right after a multibyte character parses cleanly in the wrong place, and #3082 gives non-ASCII characters tokens of their own, which takes even the accident away.

Past the end of the buffer. There is nothing left to step over, so the walk never finishes: rbs_skip stops moving at EOF while the loop waits for a byte position it will never reach.

RBS::Parser.parse_type("Integer", byte_range: 20...30) # hangs on master

This PR makes the walk report an unreachable start_pos: rbs_lexer_new and rbs_parser_new return NULL, and the C extension turns that into an ArgumentError alongside the existing reversed/negative range validation, since both are the caller's mistake rather than anything about the source text.

RBS::Parser.parse_type('"🐕🐈"', byte_range: 2...)
# => ArgumentError: position range starts inside a character: 2...10

RBS::Parser.parse_type("Integer", byte_range: 20...30)
# => ArgumentError: position range starts past the end of the buffer: 20...30, buffer is 7 bytes

end_pos keeps accepting any value: clamping with a large number instead of measuring the buffer is ordinary, and the lexer stops at the end of input on its own.

Note: this overlaps with #3082 in test/rbs/type_parsing_test.rb — both rewrite test_parse__byte_range_incorrect — so whichever lands second needs a trivial rebase.

🤖 Generated with Claude Code

@soutaro
soutaro force-pushed the reject-unreachable-start-pos branch 2 times, most recently from aa5e0a5 to a29c0f3 Compare August 12, 2026 07:19
`rbs_lexer_new` reaches `start_pos` by stepping one character at a time
from the beginning of the buffer, so only the first byte of a character
is a position it can stand on.

Inside a character the step goes over `start_pos` and lexing quietly
begins at the next one. Nothing downstream can tell: the walk keeps
`line`, `column` and `char_pos` consistent, so the result points at a
real position that simply is not the one asked for. The shift is caught
by accident when what it lands on cannot open a token, but an ASCII
letter right after the multibyte character parses cleanly in the wrong
place -- and ruby#3082 gives the non-ASCII characters tokens of
their own, taking even the accident away.

Past the end of the buffer there is nothing left to step over, so the
walk never finishes: `rbs_skip` stops moving at EOF while the loop
waits for a position it will never reach. `parse_type("Integer",
byte_range: 20...30)` hangs on master.

`rbs_lexer_new` returns `NULL` for a `start_pos` it cannot reach, and
the extension turns that into an `ArgumentError` alongside the reversed
and negative ranges, since all are the caller's mistake rather than
anything about the source text. Starting past the end is plain from the
buffer's size, so the extension rejects it before parsing; a `NULL`
that comes back after that check can only mean a start inside a
character.

`end_pos` keeps taking any value: clamping with a large number instead
of measuring the buffer is ordinary, and the lexer stops at the end on
its own.
@soutaro
soutaro force-pushed the reject-unreachable-start-pos branch from a29c0f3 to 49d5b5f Compare August 12, 2026 07:21
@soutaro
soutaro marked this pull request as ready for review August 12, 2026 07:21
@soutaro
soutaro enabled auto-merge August 12, 2026 07:22
@soutaro
soutaro added this pull request to the merge queue Aug 12, 2026
Merged via the queue into ruby:master with commit f210e6d Aug 12, 2026
24 of 25 checks passed
@soutaro
soutaro deleted the reject-unreachable-start-pos branch August 12, 2026 07:27
dak2 pushed a commit to dak2/rbs that referenced this pull request Aug 12, 2026
ruby#3083 made `rbs_lexer_new` and `rbs_parser_new` return `NULL` for a
`start_pos` the lexer cannot start on, and taught the C extension to raise
`ArgumentError` for it. The WebAssembly shim went on using the result
unchecked: linear memory has no protected page at address 0, so nothing traps
there -- the module reads and writes whatever sits at offset 0 and returns a
parse failure with an empty result, which the Ruby side then tries to decode as
an error blob. That is the `undefined method 'zero?' for nil` behind the three
JRuby failures in `RBS::TypeParsingTest`.

So the shim checks for `NULL` and reports it: the parse entry points gain a
status of their own for a `start_pos` the parser will not take, and
`RBS::Parser` turns it into the same `ArgumentError` the extension raises.
Negative and reversed ranges get a status too, rather than the parse-error one
they shared with an empty blob.

The `end_pos` rule had to move as well. The extension takes any `end_pos` --
clamping with a large number instead of measuring the buffer is ordinary, and
the lexer stops at the end of the input on its own, because a Ruby string keeps
a NUL terminator to stop at. A buffer the host wrote into linear memory has
nothing behind it, so the shim rejected anything past the end instead. It now
clamps to the buffer, which is the same position the extension stops at, and
`parse_type("Integer", byte_range: 0...9999)` parses on JRuby as it does on
CRuby.

Verified by compiling the shim natively against `src/` under ASan/UBSan (it is
plain C) and driving the entry points over exact-sized allocations: the ranges
above return their statuses with no read past the end of the buffer, and the
pre-fix shim reports the null dereference at `rbs_wasm_parse_type`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AqEsvUoBfRtqECvrGRWTpy
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.

1 participant