DEV Community

Morgan Ma
Morgan Ma

Posted on

The Parse Succeeded on a Partial Token

The parser returned success on clearly junk input. The leftover suffix never reached a single test.

I burned a morning on a green suite. Why did every generated fixture stay so quiet?

This walkthrough is reconstructed from a common failure. Treat the function names as teaching examples. The reusable debugging method is the whole point.

The symptom that looked like a win

The config token was the literal 4096k. The job then allocated only 4096 bytes.

The log printed a completely happy parse line. No error branch ran at all.

Was the allocator wrong this time? Was the log lying to me?

Neither guess survived the first hex dump. The parser stopped at the first non-digit. It still returned success to the caller.

What I guessed first

I blamed errno before reading any input bytes. I blamed the C locale right after that.

The on-disk file was plain ASCII throughout. The process locale was already set to C. Those early guesses were pure debugging theater.

I needed the leftover pointer from from_chars. Did std::from_chars actually consume the trailing k?

Why the suite stayed green

The generated tests used only clean decimal tokens. They covered 0, 1, and 4096 only.

They never sent a trailing unit suffix. They also never sent a trailing space.

The tests quietly agreed with the implementation. They did not encode the config spec.

Does this pattern sound familiar to you yet? AI authored both sides of the same mistake.

The loose parse I started from

Label this block as example code. I did not lift it from a private tree.

// EXAMPLE: silent prefix parse
#include <charconv>
#include <cstdint>
#include <string_view>
#include <system_error>

bool parse_bytes_loose(std::string_view text, std::uint64_t& out) {
    if (text.empty()) {
        return false;
    }
    std::uint64_t value = 0;
    const char* first = text.data();
    const char* last = first + text.size();
    auto [ptr, ec] = std::from_chars(first, last, value);
    if (ec != std::errc{}) {
        return false;
    }
    out = value; // BUG: ptr may not equal last
    (void)ptr;
    return true;
}
Enter fullscreen mode Exit fullscreen mode

See the unused ptr sitting there? That unused pointer is the whole bug.

std::from_chars may succeed on a prefix. The standard allows that leftover. Do you check ptr == last in review?

Most generated snippets skip that check. They copy the ec test and stop.

A fixture that hides the failure

// EXAMPLE: tautological tests
#include <cassert>
#include <cstdint>
#include <string_view>

bool parse_bytes_loose(std::string_view, std::uint64_t&);

void test_generated_fixtures() {
    std::uint64_t n = 0;
    assert(parse_bytes_loose("0", n) && n == 0);
    assert(parse_bytes_loose("1", n) && n == 1);
    assert(parse_bytes_loose("4096", n) && n == 4096);
    // Never: "4096k", "4096K", "4096 ", "0x10"
}
Enter fullscreen mode Exit fullscreen mode

Every assert matches the happy decimal path. Production input is not in that list.

Would you ship this helper today? I almost shipped it. The suite was loud and green.

Numbered debugging steps

I used this sequence on the leftover. Steal it and keep it small.

1. Freeze the raw token

Print the exact bytes before any trim. Print the length beside the hex.

Do not trim the token first. Trimming hides the suffix you need.

# EXAMPLE command
python3 -c "print(list(b'4096k')); print(len(b'4096k'))"
Enter fullscreen mode Exit fullscreen mode

You should see 53, 48, 57, 54, 107. The 107 is the letter k.

2. Split success from consumption

ec == std::errc{} is not enough. Ask how far ptr actually moved.

If ptr != last, you parsed a prefix. That prefix result is not success.

3. Add one hostile fixture

Do not add twenty new cases now. Add the token from production first.

4096k is enough for the proof. Then add 4096 and the empty string.

4. Fail the old helper on purpose

Keep the loose parser in the tree. Point the new test at that helper.

Watch it return true for 4096k. Watch out == 4096 in the debugger. That mismatch is your proof, not a theory.

5. Write the strict helper beside it

Do not “improve” the loose path in place. Side-by-side diffs teach the next review.

Then flip the single call site. Then delete the loose path after the flip.

The strict helper

// EXAMPLE: reject leftover characters
#include <charconv>
#include <cstdint>
#include <string_view>
#include <system_error>

struct ByteParse {
    bool ok;
    std::uint64_t value;
    std::size_t consumed;
    char leftover; // '\0' if none
};

ByteParse parse_bytes_strict(std::string_view text) {
    ByteParse r{false, 0, 0, '\0'};
    if (text.empty()) {
        return r;
    }
    const char* first = text.data();
    const char* last = first + text.size();
    auto [ptr, ec] = std::from_chars(first, last, r.value);
    r.consumed = static_cast<std::size_t>(ptr - first);
    r.leftover = (ptr == last) ? '\0' : *ptr;
    if (ec != std::errc{}) {
        return r;
    }
    if (ptr != last) {
        return r; // leftover suffix, still not ok
    }
    if (r.consumed == 0) {
        return r;
    }
    r.ok = true;
    return r;
}
Enter fullscreen mode Exit fullscreen mode

The leftover char is only for logs. Do not guess units inside this helper.

Want k to mean kibibytes later? That is a second parser. Keep this one honest.

A tiny self-check main

// EXAMPLE: compile with
// g++ -std=c++17 -Wall -Wextra -Werror parse_check.cpp -o parse_check
#include <iostream>
#include <string_view>

ByteParse parse_bytes_strict(std::string_view);

int main() {
    const std::string_view cases[] = {
        "0", "4096", "4096k", "4096K", "4096 ",
        "", "k4096", "18446744073709551616", "+4096", "0x10"
    };
    int failures = 0;
    for (auto c : cases) {
        auto r = parse_bytes_strict(c);
        const bool expect_ok = (c == "0" || c == "4096");
        if (r.ok != expect_ok) {
            std::cerr << "mismatch on '" << c << "'\n";
            ++failures;
        }
        std::cout << "in='" << c
                  << "' ok=" << r.ok
                  << " value=" << r.value
                  << " consumed=" << r.consumed
                  << " leftover=";
        if (r.leftover) {
            std::cout << r.leftover;
        } else {
            std::cout << "NUL";
        }
        std::cout << "\n";
    }
    return failures == 0 ? 0 : 1;
}
Enter fullscreen mode Exit fullscreen mode

Run it and read the leftover column. That column is the actual debugger.

+4096 fails on purpose in this harness. Integer from_chars does not treat + as optional padding.

Check your standard library before you assume a sign. Do not trust a comment from a chat log.

Decision table I keep above the function

Input ec looks ok? ptr == last? Ship it?
4096 yes yes yes
4096k yes no no
4096 yes no no
empty no n/a no
k4096 no no no
0x10 yes on 0 no no
overflow digits no maybe no

If a generated test only covers row one, reject the patch. Ask the patch one blunt question. Which row did you actually run?

Where a free compile loop actually helped

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

I used MonkeyCode's free model access on the helper text. I used the free server option to compile this harness.

The model suggested the loose from_chars call. That suggestion was not a surprise. The server did not magically know the config grammar.

I still had to add 4096k myself. Do not treat a green remote build as verification. Treat it as a faster rebuild loop only.

I will not name models in this article. I will not quote quotas either. Those figures go stale by the next post.

What the generated patch got wrong

It copied a success check from common snippets. It ignored the leftover pointer completely.

It wrote tests from the function shape. It did not write tests from the config grammar.

That is not a model scandal by itself. That is a missing oracle in the repo.

Who owns that oracle on your team? You do. The grammar still lives in your head.

A second trap in the same file

std::from_chars does not skip whitespace. A trailing space fails the strict check.

Good. A leading space also fails here. That failure is also good.

Hex prefixes fail in a sneakier way. 0x10 consumes 0, then leftover is x.

Did your generated tests include 0x10? The example suite above did not at first.

Add it, then watch leftover print x. That single character ends the argument.

Overflow is a different failure

The huge digit string is not a prefix bug. from_chars should report result_out_of_range.

Do not fold overflow into leftover handling. Keep those two failures separate in logs.

I print ok, consumed, and leftover together. I still inspect ec in the debugger.

One flag cannot describe both bugs. Two flags keep the postmortem honest.

Limitations of this method

This harness does not parse size units. It does not parse decimal bytes.

It does not handle thousand separators. It does not handle locale numerals.

from_chars is base ten in this helper. Binary sizes need a later stage.

The self-check compares views to literals. That is fine for fixed cases. It is not a fuzz campaign.

It will miss mixed suffixes like 4096kb. I did not measure wall time here. I did not claim any speedup.

Who should not use this approach

Do not use a remote compile loop for secrets. Config tokens can be secrets too.

Do not paste production files into a hosted model. Redact the token stream first.

Do not use this parser for money fields. Rounding and overflow need a written spec.

Do not skip sanitizers after the parse fix. Prefix parse is not your only bug class.

If you cannot state the grammar today, stop generating tests. Write the grammar down first.

What I keep from the failure

Green tests can encode the original bug. Partial consumes look exactly like success.

ptr == last is the real success check. The leftover char is the breadcrumb you log.

Hostile fixtures beat more happy fixtures. One production token is usually enough.

Would I trust another generated parser tomorrow? Only with that table above the function.

If you already hold a failing token, a free compile server can shorten the rebuilds.

Top comments (0)