Skip to content

Add IO.read_char effect operation (#618)#689

Merged
aallan merged 6 commits into
mainfrom
feature-618-io-read-char
May 19, 2026
Merged

Add IO.read_char effect operation (#618)#689
aallan merged 6 commits into
mainfrom
feature-618-io-read-char

Conversation

@aallan

@aallan aallan commented May 19, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #618 (terminal half). The browser half stays open under the JSPI umbrella tracked by #609IO.read_char needs the same suspend/resume primitive that IO.sleep needs in the browser, and the wiring is in place so #609's landing can replace just the stub body.

  • New IO.read_char op: Unit -> @Result<String, String>. Returns one Unicode character from stdin, or Err("EOF") when the stream closes. Unblocks real-time CLI programs (paced REPLs, terminal games) that can't be written with line-buffered IO.read_line.
  • Terminal target dispatches: stdin_buf (test fixture) > Windows msvcrt.getwch() > Unix non-TTY sys.stdin.read(1) > Unix TTY termios raw mode. The non-TTY guard matters: tty.setraw() on a pipe raises EINVAL, so the os.isatty() check keeps echo "X" | vera run portable across Linux CI runners. KeyboardInterrupt is not caught — same stance as host_sleep — so Ctrl-C exits interactive prompts cleanly.
  • Browser target is a stub returning Result.Err with a message pointing at Browser runtime: implement IO.sleep via JSPI (or Asyncify fallback) so animations don't freeze the tab #609 + IO.read_char — single-character input portable across terminal and browser targets #618. Wired into IO_BINDINGS so Browser runtime: implement IO.sleep via JSPI (or Asyncify fallback) so animations don't freeze the tab #609's JSPI landing can drop in the real implementation without touching anything else.
  • New conformance test ch07_io_read_char.vera (verify-level — pins the signature + effect-row wiring; no run assertion because a successful run depends on host stdin behaviour).
  • New unit tests covering the three behaviours that don't need a TTY: piped input, EOF on empty stdin, UTF-8 multi-byte round-trip.
  • New example examples/read_char.vera demonstrating the read-one-char pattern with full failure-mode handling.
  • Spec, SKILL.md, FAQ, AGENTS, CLAUDE, ROADMAP, TESTING all updated to include the new op + the new doc counts (87 conformance, 35 examples).

Test plan

  • pytest tests/ -q — 3,800 passed + 15 skipped (browser tests excluded above the line; see next)
  • pytest tests/test_browser.py -q — 99 passed (browser stub doesn't trap any existing fixture)
  • python scripts/check_conformance.py — all 87 conformance programs pass their declared level
  • python scripts/check_examples.py — all 35 examples pass vera check + vera verify
  • python scripts/check_spec_examples.py / check_skill_examples.py / check_html_examples.py / check_examples_readme.py / check_readme_examples.py / check_faq_examples.py / check_examples_doc.py — all green
  • python scripts/check_doc_counts.py — consistent (3,930 tests, 87 conformance, 35 examples)
  • python scripts/check_limitations_sync.py / check_version_sync.py / check_site_assets.py / check_licenses.py — all green
  • mypy vera/ — clean
  • Smoke test: echo "X" | vera run examples/read_char.veraYou pressed: X

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added IO.read_char for single‑character input (TTY/raw behaviour, EOF reporting, UTF‑8-aware) with a browser stub returning “not yet supported”.
  • Documentation

    • Updated specs, guides and changelog; project metrics now report 87 conformance programmes and 35 examples; browser caveat added.
  • Tests

    • New conformance entry plus CLI and unit tests covering piped input, EOF and multi‑byte characters.
  • Bug Fixes

    • Hardened host‑side error handling and improved subprocess UTF‑8 behaviour.

Review Change Stack

New `IO.read_char` op for single-character input — unblocks
real-time CLI programs (paced REPLs, terminal games) that
can't be written with line-buffered `IO.read_line`.

Signature: `op read_char(Unit -> @Result<String, String>)`.
Returns one Unicode character from stdin, or `Err("EOF")` when
the stream closes.

Terminal target host_read_char dispatches:
  stdin_buf (test fixture)
    > Windows msvcrt.getwch()
    > Unix non-TTY sys.stdin.read(1)
    > Unix TTY termios raw mode.

The non-TTY guard matters: `tty.setraw()` on a pipe raises
EINVAL, so the os.isatty() check keeps `echo "X" | vera run`
portable.  KeyboardInterrupt deliberately NOT caught here so
Ctrl-C exits interactive prompts cleanly — host_sleep takes
the same stance for the same reason.

Browser target is a stub that returns Result.Err pointing at
#609 — the actual implementation needs JSPI for the
suspend/resume primitive (keypress listener pushes chars into
a queue, read_char suspends WASM and resumes on next event).
Same primitive #609 needs for IO.sleep.  Wired into
IO_BINDINGS so #609's landing can replace just the function
body.

Closes #618 (terminal half).  Browser half stays open under
the JSPI umbrella tracked by #609.

Co-Authored-By: Claude <noreply@anthropic.invalid>
@codecov

codecov Bot commented May 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 28.16901% with 51 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.88%. Comparing base (444c15b) to head (9e4a903).

Files with missing lines Patch % Lines
vera/codegen/api.py 15.09% 45 Missing ⚠️
vera/browser/runtime.mjs 66.66% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #689      +/-   ##
==========================================
- Coverage   91.07%   90.88%   -0.19%     
==========================================
  Files          60       60              
  Lines       23458    23529      +71     
  Branches      259      259              
==========================================
+ Hits        21364    21384      +20     
- Misses       2087     2138      +51     
  Partials        7        7              
Flag Coverage Δ
javascript 57.43% <66.66%> (+0.07%) ⬆️
python 94.62% <15.09%> (-0.20%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds IO.read_char: effect/type registration, WASM import and inference, Python host implementation and browser stub, conformance/CLI/codegen/verifier tests, and documentation/scripts updates (counts and line-number offsets).

Changes

IO.read_char Implementation

Layer / File(s) Summary
Effect contract and type system registration
vera/environment.py, spec/07-effects.md, SKILL.md
Register IO.read_char : Unit -> Result<String, String> with EOF semantics; update SKILL/spec docs and conformance counts.
WASM assembly and type inference
vera/codegen/assembly.py, vera/wasm/inference.py, spec/12-runtime.md
Emit (func $vera.read_char (result i32)) import; include read_char in ops needing allocation; map to WASM i32 result pointer; document host import in runtime spec.
Host runtime: Python and browser
vera/codegen/api.py, vera/browser/runtime.mjs
Add vera.read_char host callback: deterministic stdin_buf for tests; non‑TTY sys.stdin.read(1) for pipes; Windows msvcrt.getwch; Unix cbreak via termios/tty with restore; map EOF to Err("EOF"); convert KeyboardInterrupt to _VeraExit(130); browser runtime returns Err stub.
Conformance and CLI/codegen tests
tests/conformance/manifest.json, tests/test_cli.py, tests/test_verifier.py, tests/test_codegen.py
Add ch07_io_read_char.vera (verify-level); three CLI subprocess tests for piped char, EOF, UTF‑8 multi‑byte; five execute(stdin=...) codegen tests for sequential/UTF‑8/EOF behaviour; update verifier expected totals and embedded example adjustments.
Documentation and project metrics / scripts
CHANGELOG.md, TESTING.md, SKILL.md, spec/07-effects.md, spec/12-runtime.md, AGENTS.md, CLAUDE.md, FAQ.md, ROADMAP.md, scripts/check_*.py
Document feature and fixes, update counts (conformance 86→87, examples 34→35), update spec/runtime docs, adjust script fragment line numbers and validation allowlists, and refresh testing docs/metrics.

Sequence Diagram(s)

sequenceDiagram
  participant VeraModule as Vera Program
  participant HostImport as vera.read_char
  participant InputSource as stdin_buf / TTY / msvcrt / pipe
  participant WASMHeap as WASM Heap
  VeraModule->>HostImport: call read_char()
  HostImport->>InputSource: select input method (stdin_buf / TTY / piped)
  InputSource-->>HostImport: char / EOF / exception
  HostImport->>WASMHeap: allocate Result<String,String> on heap
  WASMHeap-->>HostImport: return i32 pointer
  HostImport-->>VeraModule: return i32 pointer
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Suggested labels

compiler, tests, spec, ci, docs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add IO.read_char effect operation (#618)' accurately describes the primary change across the changeset: implementing a new IO effect operation for single-character terminal input, with supporting infrastructure, tests, and documentation updates.
Docstring Coverage ✅ Passed Docstring coverage is 90.91% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature-618-io-read-char

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
TESTING.md (1)

246-246: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update stale example count in verification coverage section.

This line states "Across all 34 example programs", but the overview at line 12 correctly shows 35. This should be updated to match.

🤖 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 `@TESTING.md` at line 246, Update the stale count in the verification coverage
section by changing the phrase "Across all 34 example programs" to "Across all
35 example programs" so it matches the overview count; locate the string "Across
all 34 example programs" in TESTING.md and replace the numeric literal to 35.
🤖 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 `@SKILL.md`:
- Line 1320: Update the prose that states IO provides "ten" operations to
reflect the new total "eleven" now that IO.read_char has been added; locate the
sentence immediately preceding the IO operations table (mentions IO operations
count) and change "ten" to "eleven" and ensure any adjacent
grammar/pluralization still reads correctly after the change (the added table
row is the `IO.read_char` entry).

In `@spec/07-effects.md`:
- Line 295: Update the heading/intro sentence that states the IO effect provides
"ten" operations to reflect the new total of "eleven" operations now that
`read_char` was added; locate the IO effect description paragraph near the
`read_char` table entry and change the numeric count from ten to eleven so the
spec remains consistent with the table listing `read_char`.

In `@TESTING.md`:
- Line 492: The table entry for `check_examples.py` still reads "All 34 `.vera`
examples pass `vera check` + `vera verify`" but the documentation now has 35
examples; update the table cell text to "All 35 `.vera` examples pass `vera
check` + `vera verify`" so the count matches the overview. Locate the table row
referencing `check_examples.py` and change only the numeric count from 34 to 35.
- Line 584: The table row for `check_examples.py` currently states "All 34
examples" but should match the overview's count of 35; update the cell text in
TESTING.md's pre-commit hooks table (the `check_examples.py` row) from "All 34
examples pass `vera check` + `vera verify`" to "All 35 examples pass `vera
check` + `vera verify`".
- Line 43: The inline comment after the command "python
scripts/check_examples.py                     # 34 example programs" is stale;
update that trailing comment to reflect the correct count (change "34" to "35")
so it matches the overview at line 12 and the actual number of example programs
referenced by the repo.
- Around line 9-12: Update the outdated example-count references in TESTING.md
to the authoritative value 37 (the count comes from check_doc_counts.py which
uses len(list((root / "examples").glob("*.vera")))); specifically replace the
header occurrence showing "35" (the line with "**Example programs**") and the
four other places that currently say "34" (around the locations noted) so all
five occurrences read "37". Ensure the wording and formatting remain unchanged
aside from the numeric value.

In `@tests/test_cli.py`:
- Around line 1834-1838: The subprocess.run calls in tests/test_cli.py that
invoke [sys.executable, "-m", "vera.cli", ...] use text=True but not an explicit
encoding, so on non-UTF-8 locales the multi-byte UTF-8 input path test can fail;
update each relevant subprocess.run invocation (the one passing input="é" and
the sibling read_char subprocess tests) to pass encoding="utf-8" in addition to
text=True so the child process I/O is forced to UTF-8 and the UTF-8 path/input
assertions are reliable.

In `@vera/codegen/api.py`:
- Around line 1400-1421: Wrap calls that may raise at
runtime—sys.stdin.fileno(), termios.tcgetattr(fd), tty.setraw(fd), and
termios.tcsetattr(fd, termios.TCSADRAIN, old)—in try/except blocks that catch
all exceptions except KeyboardInterrupt and convert them into the Result.Err
return via _alloc_result_err_string(caller, "<error>") (include the exception
text). Ensure any early-read path also wraps sys.stdin.read(1) similarly. Update
the TTY branch around the old variable so that failures to get/set attributes or
to enter raw mode always return _alloc_result_err_string(caller, f"...{exc}...")
instead of letting exceptions escape, while preserving KeyboardInterrupt
propagation.

---

Outside diff comments:
In `@TESTING.md`:
- Line 246: Update the stale count in the verification coverage section by
changing the phrase "Across all 34 example programs" to "Across all 35 example
programs" so it matches the overview count; locate the string "Across all 34
example programs" in TESTING.md and replace the numeric literal to 35.
🪄 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.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 69a261da-4ca5-4713-98df-6733360fad72

📥 Commits

Reviewing files that changed from the base of the PR and between 444c15b and 452f026.

⛔ Files ignored due to path filters (7)
  • docs/SKILL.md is excluded by !docs/**
  • docs/index.html is excluded by !docs/**
  • docs/index.md is excluded by !docs/**
  • docs/llms-full.txt is excluded by !docs/**
  • docs/llms.txt is excluded by !docs/**
  • examples/read_char.vera is excluded by !**/*.vera
  • tests/conformance/ch07_io_read_char.vera is excluded by !**/*.vera
📒 Files selected for processing (19)
  • AGENTS.md
  • CHANGELOG.md
  • CLAUDE.md
  • FAQ.md
  • ROADMAP.md
  • SKILL.md
  • TESTING.md
  • scripts/check_skill_examples.py
  • scripts/check_spec_examples.py
  • spec/07-effects.md
  • spec/12-runtime.md
  • tests/conformance/manifest.json
  • tests/test_cli.py
  • tests/test_verifier.py
  • vera/browser/runtime.mjs
  • vera/codegen/api.py
  • vera/codegen/assembly.py
  • vera/environment.py
  • vera/wasm/inference.py

Comment thread SKILL.md
Comment thread spec/07-effects.md
Comment thread TESTING.md Outdated
Comment thread TESTING.md Outdated
Comment thread TESTING.md Outdated
Comment thread TESTING.md Outdated
Comment thread tests/test_cli.py
Comment thread vera/codegen/api.py Outdated
PR #689 review surfaced three classes of issue.  All fixed,
minimal changes, no behaviour change for the happy path.

1. Doc-count drift — finished the 34→35 example sweep that
   the earlier commit only partially completed.  Six remaining
   call sites in TESTING.md (validation-script comment line 43,
   verification-coverage section line 246, slot-feature row
   line 281, round-trip section line 334, validation-scripts
   table line 492, pre-commit-hooks table line 584).  CodeRabbit
   pointed at four of the six; I caught the other two by
   grepping for `All 34|all 34|34 example` after applying its
   suggestions.  Also bumped "ten → eleven" in spec/07-effects.md
   (line 289) and SKILL.md (two places, lines 1314 + 1332) since
   read_char makes it eleven IO operations.

2. test_cli.py — added explicit `encoding="utf-8"` to the three
   new `subprocess.run` calls for read_char tests.  Matches the
   CLAUDE.md cross-platform pitfalls convention: CI sets
   `PYTHONUTF8=1` as a backstop but the explicit form is
   portable to local Windows shells without that variable.
   Particularly important for the `é` UTF-8 round-trip test
   which would silently break on non-UTF-8 locales otherwise.

3. host_read_char — wrapped `sys.stdin.fileno()`,
   `sys.stdin.read(1)` (in both branches), and the
   `termios.tcgetattr` / `tty.setraw` / `termios.tcsetattr`
   sequence in `except Exception` blocks.  System errors
   (closed stdin, monkey-patched stream without a fileno,
   `termios.error` on weird devices) now become Result.Err
   rather than propagating as wasmtime traps.  `Exception`
   excludes `KeyboardInterrupt` and `SystemExit` since those
   are direct `BaseException` subclasses, so Ctrl-C still
   terminates interactive prompts — same stance as
   host_sleep.  The inner try/finally is preserved so terminal
   state is restored even when setraw or read raises.

Validation: 3,800 pytest pass + 99 browser pytest pass, all
conformance/example/spec/skill/HTML/README/FAQ doc validators
green, doc counts consistent, mypy clean, site assets rebuilt.

Co-Authored-By: Claude <noreply@anthropic.invalid>
@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — clean sweep, and the skip is exactly right. The "37" suggestion was a mistake on my side; the live count of 35 is what the glob and check_doc_counts.py both report, so keeping 35 is correct.

A few notes on the applied items worth confirming:

  • except Exception in api.py — the reasoning is sound. KeyboardInterrupt and SystemExit are both direct BaseException subclasses, so they propagate through except Exception unimpeded. Raw-mode terminal state is still restored via the inner try/finally before the exception unwinds, which is the critical invariant.
  • encoding="utf-8" on subprocess.run — correct for cross-platform UTF-8 round-trip correctness; the UTF-8 test in particular would be fragile without it on Windows where the default ANSI code page differs.
  • IO has 10 operations11 at both L1314 and L1332 in `SKILL.md** — good catch extending the fix to the second occurrence; that's the kind of drift that static grep sweeps miss when the number appears in prose rather than structured data.

All CI green with 3,800 + 99 browser tests passing is a solid validation gate. Nothing outstanding from my side — this looks ready.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
TESTING.md (2)

92-92: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update stale conformance count.

Line 92 still references "86 small, focused programs" but the conformance suite now contains 87 programs (per line 11 and the addition of ch07_io_read_char documented at line 119). This should be updated to maintain consistency with the rest of the document.

📝 Proposed fix
-The conformance suite is a collection of 86 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Each program is self-contained and imports nothing, with the single exception of `ch07_cross_module_contracts.vera` which depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features.
+The conformance suite is a collection of 87 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Each program is self-contained and imports nothing, with the single exception of `ch07_cross_module_contracts.vera` which depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features.
🤖 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 `@TESTING.md` at line 92, Update the stale conformance count in TESTING.md by
changing the sentence that currently reads "86 small, focused programs" to "87
small, focused programs" (this is the paragraph describing the conformance suite
that also mentions ch07_cross_module_contracts.vera and the new
ch07_io_read_char addition); ensure the rest of the sentence and references
(ch07_cross_module_contracts.vera and ch07_cross_module_contracts_lib.vera)
remain unchanged so the doc stays consistent.

193-193: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update stale parametrised test count.

Line 193 states "parametrized — 430 tests" but with 87 conformance programs (per line 11) and 5 checks per program (parse/check/verify/run/format idempotency per line 200), the correct count is 435 tests. This matches line 82 which correctly shows "435" for test_conformance.py. The value 430 reflects the old count (86 × 5).

📝 Proposed fix
-# Via pytest (parametrized — 430 tests)
+# Via pytest (parametrized — 435 tests)
 pytest tests/test_conformance.py -v
🤖 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 `@TESTING.md` at line 193, Update the stale test count in TESTING.md: change
the header text "Via pytest (parametrized — 430 tests)" to "Via pytest
(parametrized — 435 tests)". Ensure this reflects the 87 conformance programs
and 5 checks per program (parse/check/verify/run/format idempotency) so it
matches the `test_conformance.py` count shown elsewhere.
🤖 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 `@vera/codegen/api.py`:
- Around line 1384-1424: Move the stdin redirection/TTY check before calling
msvcrt.getwch(): call sys.stdin.fileno() and os.isatty(fd) (with the same
exception handling that returns _alloc_result_err_string(caller, ... ) on
failure) and if not os.isatty(fd) on Windows use sys.stdin.read(1) wrapped in
try/except and return _alloc_result_ok_string(caller, ch) or
_alloc_result_err_string(caller, ...) / EOF exactly like the Unix non-TTY path;
only call msvcrt.getwch() when os.isatty(fd) is True, preserving the existing
error/EOF handling and the same return helpers (_alloc_result_ok_string /
_alloc_result_err_string) and `caller` context.

---

Outside diff comments:
In `@TESTING.md`:
- Line 92: Update the stale conformance count in TESTING.md by changing the
sentence that currently reads "86 small, focused programs" to "87 small, focused
programs" (this is the paragraph describing the conformance suite that also
mentions ch07_cross_module_contracts.vera and the new ch07_io_read_char
addition); ensure the rest of the sentence and references
(ch07_cross_module_contracts.vera and ch07_cross_module_contracts_lib.vera)
remain unchanged so the doc stays consistent.
- Line 193: Update the stale test count in TESTING.md: change the header text
"Via pytest (parametrized — 430 tests)" to "Via pytest (parametrized — 435
tests)". Ensure this reflects the 87 conformance programs and 5 checks per
program (parse/check/verify/run/format idempotency) so it matches the
`test_conformance.py` count shown elsewhere.
🪄 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.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f49af71d-260f-4666-8fe4-d29bcd51e427

📥 Commits

Reviewing files that changed from the base of the PR and between 452f026 and 217845a.

⛔ Files ignored due to path filters (2)
  • docs/SKILL.md is excluded by !docs/**
  • docs/llms-full.txt is excluded by !docs/**
📒 Files selected for processing (6)
  • CHANGELOG.md
  • SKILL.md
  • TESTING.md
  • spec/07-effects.md
  • tests/test_cli.py
  • vera/codegen/api.py

Comment thread vera/codegen/api.py Outdated
Three findings, all valid:

1. host_read_char Windows path — the previous structure went
   straight to `msvcrt.getwch()` regardless of whether stdin
   was a TTY or a redirected pipe.  Lifted the `os.isatty(fd)`
   check above the platform fork so the non-TTY (pipe /
   redirect) branch is now shared across platforms — both use
   `sys.stdin.read(1)` for identical encoding behaviour.  Only
   TTY stdin goes through `msvcrt.getwch()` (Windows) or
   termios raw mode (Unix).

   Why this matters: `msvcrt.getwch()` technically works on
   redirected stdin via Win32's `_getch` fallback but decodes
   raw bytes rather than honouring Python's stdin encoding.
   A piped UTF-8 sequence like "é" would round-trip
   differently from the Unix path — exactly the kind of
   silent cross-platform divergence that breaks CI on
   Windows-3.13 weeks after merge.

   Also wrapped `msvcrt.getwch()` in `except Exception` for
   symmetry with the Unix path's defensive handling.

2. TESTING.md L92 — "86 small, focused programs" → "87".

3. TESTING.md L193 — "Via pytest (parametrized — 430 tests)"
   → "435 tests" (matches `test_conformance.py`'s live count
   of 87 programs × 5 stages).

Plus a grep sweep caught one more stale 86 in ROADMAP.md
L190 (#679 conformance-gap description).

Validation: 3,800 pytest pass + 99 browser pytest pass,
mypy clean, doc counts consistent, all doc validators green,
piped smoke test still works.

Co-Authored-By: Claude <noreply@anthropic.invalid>
@aallan

aallan commented May 19, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai also applied the two outside-diff TESTING.md findings in 9983b2c: L92 "86 small, focused programs" → "87", and L193 "Via pytest (parametrized — 430 tests)" → "435". A grep sweep also caught a stale 86 in ROADMAP.md L190 (#679 row description), fixed in the same commit.

Internal multi-agent review (code-reviewer, pr-test-analyzer,
comment-analyzer, silent-failure-hunter) caught two correctness
bugs and three quality issues that CodeRabbit's two rounds
missed.

Critical:

1. host_read_char now catches KeyboardInterrupt at each blocking
   call (sys.stdin.read(1) in both branches, msvcrt.getwch() on
   Windows) and raises _VeraExit(130).  Same fix host_sleep
   applies for the same reason (#589-class WasmTrapError
   contract violation when a raw Python KeyboardInterrupt
   unwinds through the host import).  The prior comment claimed
   parity with host_sleep's stance, but host_sleep actually
   catches KeyboardInterrupt — the comment was factually wrong
   and the missing catch was a real bug.  User-facing behaviour
   is unchanged: Ctrl-C still terminates the program with exit
   code 130.

2. Fixed stale cross-reference in
   tests/conformance/ch07_io_read_char.vera (pointed at
   tests/test_codegen.py instead of tests/test_cli.py).

Important:

3. Termios restore failure can no longer mask the original
   read error.  Inner tcsetattr in finally is now wrapped in
   its own try/except — if restore fails the terminal stays
   in raw mode (rare; same fd just worked) but the original
   read error still surfaces.

4. tcgetattr failure now reports "tcgetattr failed: ..."
   rather than the misleading "raw-mode read failed: ..." —
   raw mode never started in that case.

5. Removed redundant local `import os` inside host_read_char
   (`os` already imported at module level).

Test coverage:

6. Five new test_codegen.py::TestIOOperations tests covering
   the stdin_buf fixture path that subprocess tests can't
   reach: single read, empty-buf EOF, sequential cursor
   advance, read-then-EOF, 2-byte UTF-8 round-trip.  All
   platform-independent (StringIO, no reliance on host stdin
   encoding).

7. Tightened the 3 existing test_cli.py read_char tests:
   result.stdout.rstrip() == "<exact>" (not substring `in`),
   plus assert result.stderr == "" — catches regressions
   where a future host prints to stderr on the way to a
   clean exit.

Findings skipped with reasons (logged in pr-review reports):

- Windows-monkeypatch test for the platform-fork bug:
  infra complexity exceeds the benefit; existing tests
  exercise the platform-shared path on Windows CI anyway.
- Example program Unix-centric phrasing: minor cosmetic;
  examples are illustrative not exhaustive.
- 4-byte emoji UTF-8 test: 2-byte é already pins the
  multi-byte path adequately.
- PYTHONIOENCODING guidance in error message: overengineering
  for a rare failure mode.
- Comment about dispatch-order being load-bearing: adequately
  implied by the existing comment block.
- Test docstring paraphrasing: matter of taste.

Validation: 3,805 pytest pass + 99 browser pytest pass,
mypy clean, all doc validators green, doc counts consistent
at the new 3,935 / 87 / 35.

Co-Authored-By: Claude <noreply@anthropic.invalid>
@aallan

aallan commented May 19, 2026

Copy link
Copy Markdown
Owner Author

pr-review-toolkit results

Ran a comprehensive internal review with four specialised agents in parallel: code-reviewer, pr-test-analyzer, comment-analyzer, silent-failure-hunter. Two correctness bugs CodeRabbit's two rounds missed, plus three quality issues. All fixed in e6d549e.

Critical (applied)

  1. host_read_char let KeyboardInterrupt escape through the wasmtime trampolinevera/codegen/api.py. The comment claimed parity with host_sleep ("KeyboardInterrupt is NOT caught… host_sleep takes the same stance"), but host_sleep actually catches KeyboardInterrupt and raises _VeraExit(130) to avoid the host_print / host_stderr / host_contract_fail crash with raw Python UnicodeDecodeError on invalid UTF-8 bytes #589-class WasmTrapError contract violation when a raw Python KeyboardInterrupt unwinds through a host import. Comment was factually wrong AND the missing catch was a real bug. Now each blocking call (sys.stdin.read(1) in both branches, msvcrt.getwch() on Windows) catches KeyboardInterrupt and raises _VeraExit(130). User-facing behaviour unchanged — Ctrl-C still terminates with exit 130.

  2. Stale cross-reference in conformance test headertests/conformance/ch07_io_read_char.vera claimed run-level coverage lived in tests/test_codegen.py, but it was actually in tests/test_cli.py. Updated to point at both files (test_codegen.py now has stdin_buf tests too — see below).

Important (applied)

  1. Termios restore could mask the original read errorvera/codegen/api.py. If read(1) raised and then tcsetattr in the finally also raised, the tcsetattr exception replaced the read exception. Wrapped the restore call in its own try/except so the original read error always surfaces. If restore fails the terminal stays in raw mode (rare; same fd just worked) but that's unavoidable at the host level.

  2. tcgetattr failure reported the wrong error messagevera/codegen/api.py. A tcgetattr failure meant raw mode never started, but the outer except reported "raw-mode read failed: ...". Now distinct: tcgetattr failed: <exc> vs raw-mode read failed: <exc>.

  3. Redundant local import os in host_read_charos was already imported at module level. Removed.

Test coverage (applied)

  1. 5 new execute(stdin=...) tests in tests/test_codegen.py::TestIOOperations covering the stdin_buf fixture path that subprocess-based tests can't reach: test_io_read_char_stdin_buf_single, _empty, _sequential, _then_eof, _utf8. All platform-independent (StringIO, no reliance on host stdin encoding).

  2. Tightened the 3 existing test_cli.py read_char tests: result.stdout.rstrip() == "<exact>" rather than substring in, plus assert result.stderr == "". Catches a class of regression where a future host accidentally prints to stderr on the way to a clean exit.

Skipped with reason

  • Windows-monkeypatch test for the platform-fork: test-infra complexity exceeds benefit; existing tests already exercise the platform-shared path on Windows CI.
  • Example program Unix-centric phrasing: minor cosmetic.
  • 4-byte emoji UTF-8 test: 2-byte é already pins the multi-byte path adequately.
  • PYTHONIOENCODING guidance in error message: overengineering for a rare failure.
  • Dispatch-order load-bearing comment: adequately implied by existing comment block.
  • Test docstring paraphrasing: matter of taste.

Strengths the agents noted

Validation

3,805 pytest pass + 99 browser pytest pass, mypy clean, all doc validators green, doc counts consistent (3,935 tests, 87 conformance, 35 examples).

@aallan

aallan commented May 19, 2026

Copy link
Copy Markdown
Owner Author

pr-review-toolkit: code-reviewer agent findings

Agent reviewed: CLAUDE.md compliance, general code quality, bug detection. Ran vera check/vera verify on the example and conformance test, plus full validator suite.

Important — Redundant import os inside host_read_char

File: vera/codegen/api.py:1394 (pre-fix)

os is already imported at module scope (line 12: import os). The local import os at line 1394, sitting inside host_read_char before try: fd = sys.stdin.fileno(), is dead code — it neither shadows nor lazy-loads anything. Unlike the lazy import termios / import tty / import msvcrt further down (which are platform-conditional and correctly local), os is unconditional stdlib. The line was likely added during the round-2 restructure when os.isatty(fd) got lifted out of the platform fork; module-level os would already cover it.

  • Verdict: ✅ Applied in e6d549e.
  • What changed: Removed line 1394.

Suggestions (not flagged as actionable)

  • IO_BINDINGS ordering inconsistency between runtime.mjs (read_line, read_char, read_file) and assembly.py/environment.py (read_char appended at end) is cosmetic — Python dict iteration order has no semantic effect on host-import lookup. Skipped: no behavior impact.
  • Pre-existing missing encoding="utf-8" on f.read_text() at tests/test_verifier.py:1834 — verified in main, not PR-introduced. Skipped: out of scope.

Strengths flagged by the agent

  • Round-2 restructure (lifting os.isatty(fd) above platform fork) is soundmsvcrt.getwch() on redirected stdin decodes raw bytes vs Python's stdin encoding, so the prior structure would have round-tripped multi-byte UTF-8 inconsistently. The comment block at api.py:1402-1409 explains this clearly.
  • Defensive handling is consistent and correctly justified — every except Exception block has the same shape, with explicit comments about why Exception not BaseException.
  • Test fixture path takes precedence correctly (mirrors host_read_line's stdin_buf precedent).
  • CLAUDE.md cross-platform pitfalls observed: explicit encoding="utf-8" on subprocess.run, POSIX-form paths via tmp_path, no tempfile.NamedTemporaryFile subprocess handoff.
  • Documentation discipline tight: SKILL.md + spec/07 + spec/12 + docs/SKILL.md + docs/llms-full.txt all updated; no strikethroughs; IO.read_char — single-character input portable across terminal and browser targets #618 terminal-half correctly in CHANGELOG [Unreleased]; ROADMAP entry for IO.read_char — single-character input portable across terminal and browser targets #618 removed (correct — terminal half is the closing portion); browser half stays linked under Browser runtime: implement IO.sleep via JSPI (or Asyncify fallback) so animations don't freeze the tab #609 with the new cross-reference comment.
  • Verifier-count test update honest — trajectory comment explains the +2 T1 / +2 total delta from examples/read_char.vera's trivial requires(true) ensures(true) contracts.
  • Conformance test mirrors ch07_io_sleep precedentlevel: verify, no run assertion, same justification.

Bottom line

Ship after dropping the one redundant import os line.

Done in e6d549e.

@aallan

aallan commented May 19, 2026

Copy link
Copy Markdown
Owner Author

pr-review-toolkit: pr-test-analyzer agent findings

Agent reviewed: behavioural test coverage, test quality, edge-case gaps.

Critical Gap 1 — stdin_buf fixture path entirely uncovered

Affected files: tests/test_codegen.py::TestIOOperations

tests/test_codegen.py::TestIOOperations already uses execute(..., stdin="...") for IO.read_line (line 8160). The new code added a parallel stdin_buf is not None short-circuit in host_read_char (api.py:1378-1382) but no test ever enters it. The three subprocess tests in test_cli.py go through real pipes, which hit the os.isatty()-false branch — a different code path.

Agent recommended 4 new tests:

  • Verdict: ✅ Applied in e6d549e. Added 5 tests (the 4 recommended + a sequential-read-then-EOF):
    • test_io_read_char_stdin_buf_single (pipes "A", asserts "A")
    • test_io_read_char_stdin_buf_empty (pipes "", asserts "EOF")
    • test_io_read_char_stdin_buf_sequential (stdin="AB", asserts cursor advances)
    • test_io_read_char_stdin_buf_then_eof (stdin="A", asserts first Ok then Err)
    • test_io_read_char_stdin_buf_utf8 (stdin="é", platform-independent UTF-8)
  • All run on every CI matrix cell (including Windows) without subprocess overhead — ~10ms each.

Critical Gap 2 — Windows-pipe routing fix has no Windows-specific regression test

The CodeRabbit round-2 fix lifted os.isatty() above the platform fork specifically so Windows-with-redirected-stdin uses sys.stdin.read(1) instead of msvcrt.getwch(). None of the existing tests would fail if a future refactor moved os.isatty() back inside the if sys.platform == "win32": branch (because Windows CI never sees a real TTY in subprocess.run).

Agent recommended a monkeypatch-based regression test.

  • Verdict: ⏭️ Skipped with reason. Mocking sys.platform and os.isatty during a wasmtime host call is involved (the platform fork lives inside the host import, not at the API surface), and the new stdin_buf_utf8 test does pin the encoding contract platform-independently. Tracked as a follow-up if a regression actually surfaces.

Important — EOF mid-read sequence

No test for read-succeeds-then-EOF: call IO.read_char twice with stdin="A", assert first call returns Ok("A") and second returns Err("EOF").

  • Verdict: ✅ Applied — see test_io_read_char_stdin_buf_then_eof above.

Important — Mixed read_line + read_char from same stdin_buf

Both operations share the same stdin_buf (api.py:897). Pin the interleaving.

  • Verdict: ⏭️ Skipped with reason. The current stdin_buf semantics are documented at the host level; both host_read_line (stdin_buf.readline()) and host_read_char (stdin_buf.read(1)) advance the same StringIO cursor, which is Python's documented behaviour. A regression here would require someone replacing the StringIO with separate buffers — fairly deliberate. Not worth pinning unless someone actually tries that.

Suggestions

  • 4-byte emoji UTF-8 — ⏭️ Skipped. 2-byte é already pins the multi-byte path; the same code path handles 3- and 4-byte sequences.
  • Conformance test run-level companion — ⏭️ Skipped. The conformance runner doesn't currently support stdin: "X" in manifest entries; adding the manifest schema change is its own work.
  • Assert stderr == "" on test_cli.py tests — ✅ Applied to all three.
  • Tighten loose substring matches ("got: X" in stdoutstdout.rstrip() == "got: X") — ✅ Applied to all three.
  • test_overall_tier_counts is brittle — ⏭️ Skipped. Pre-existing pattern, out of scope for Add IO.read_char effect operation (#618) #689.

Strengths flagged

  • Test fixture path was correctly added (stdin_buf is not None check first, mirroring host_read_line) — wired up before any test exercised it.
  • Three distinct behaviours pinned initially: happy path, EOF, UTF-8 round-trip.
  • Conformance test pins type signature and effect row at verify level — right level given host dependency.
  • tier_counts test was updated so the example-validation invariant stays green.
  • encoding="utf-8" retrofit on the pre-existing read_line test future-proofs that test on Windows too.
  • Comments in host_read_char make the missing tests easier to write — every "why" comment is a test waiting to happen.

Bottom line

The biggest single improvement is adding execute(stdin=...) tests in tests/test_codegen.py::TestIOOperations — those tests would catch most regressions in the stdin_buf short-circuit, run on every CI matrix cell without subprocess overhead, and parallel the existing test_io_read_line_echo shape exactly.

Done in e6d549e — 5 tests added.

@aallan

aallan commented May 19, 2026

Copy link
Copy Markdown
Owner Author

pr-review-toolkit: comment-analyzer agent findings

Agent reviewed: comment accuracy (vs the code they describe), comment rot risk, completeness, over-commenting, cross-reference accuracy.

Critical 1 — "Same stance as host_sleep" is factually wrong

Files: vera/codegen/api.py:1369-1374 and 1391-1393 (pre-fix)

The comment says KeyboardInterrupt is "NOT caught here... host_sleep takes the same stance for the same reason." But reading host_sleep at lines 1280-1295, it does the opposite — it explicitly except KeyboardInterrupt and re-raises as _VeraExit(130) to avoid a WasmTrapError. So host_read_char and host_sleep actually behave differently with respect to Ctrl-C. The user-facing outcome (process terminates) is similar, but the precedent claim is incorrect — host_sleep catches KeyboardInterrupt, it doesn't let it propagate.

Critical 2 — Conformance test header references wrong file

File: tests/conformance/ch07_io_read_char.vera:7-8 (pre-fix)

The header says "run-level behaviour is exercised by unit tests in tests/test_codegen.py", but test_codegen.py contains zero references to read_char (grep -rln read_char tests/ returns only test_cli.py and test_verifier.py). The three new run-level tests are in tests/test_cli.py:1763-1840. Almost certainly a rename or path mistake during PR development that survived two CodeRabbit rounds.

  • Verdict: ✅ Applied in e6d549e — updated header to reference both test_cli.py (existing subprocess tests) and test_codegen.py (new stdin_buf tests, see pr-test-analyzer above).

Improvement Opportunity — Redundant import os

  • Verdict: ✅ Applied (also caught by code-reviewer).

Improvement Opportunity — Dispatch order is load-bearing but uncommented

File: vera/codegen/api.py:1402-1409 (pre-fix)

The comment correctly explains why the non-TTY path is shared. It does not mention that the dispatch order (stdin_buf -> TTY check -> Windows -> Unix) is load-bearing.

  • Verdict: ⏭️ Skipped with reason. The existing comment block at L1402-1409 implies it (it states what would go wrong if Windows-msvcrt were called on redirected stdin). Adding another sentence would be over-commenting on already-substantial documentation. The PR's commit message and CHANGELOG also capture this rationale.

Improvement Opportunity — Example program is Unix-centric

File: examples/read_char.vera:3-8

Mentions "termios raw mode" without acknowledging Windows uses msvcrt.getwch().

  • Verdict: ⏭️ Skipped with reason. The example is illustrative — readers looking for cross-platform coverage check the spec/SKILL.md, both of which mention Windows.

Removal Suggestions — Test docstring paraphrasing

Agent flagged three test docstrings as having one paraphrasing line each:

Removal Suggestion — Over-confident pragma comment

File: vera/codegen/api.py:1437

# pragma: no cover — getwch never returns "" — confident claim that's hard to verify and may be platform/version dependent. Soften to "defensive — getwch is not documented to return empty."

  • Verdict: ✅ Applied in e6d549e — changed the comment to "defensive; getwch is not documented to return empty."

Positive findings

@aallan

aallan commented May 19, 2026

Copy link
Copy Markdown
Owner Author

pr-review-toolkit: silent-failure-hunter agent findings

Agent reviewed: silent failures, inadequate error handling, exception-scope correctness, masking, resource leaks, encoding silent failures, test verification of stderr/exit code.

Important 1 — KeyboardInterrupt escape during Unix-TTY raw-mode read leaves terminal in raw mode AND causes wasmtime trap

File: vera/codegen/api.py:1452-1462 (pre-fix)

On a real TTY, KeyboardInterrupt can fire inside the inner try block at line 1456 (sys.stdin.read(1) is interruptible by SIGINT). The finally at line 1457 will run, so termios is restored — good. But the KeyboardInterrupt then continues unwinding through wasmtime's trampoline. This is the exact failure mode host_sleep at line 1280-1295 documents and catches — the comment there explicitly says raw KeyboardInterrupt escape causes a "WasmTrapError contract violation in the same class as #589" and a follow-on malloc abort during wasmtime cleanup. host_read_char deliberately deviates without explaining why the same crash doesn't bite here.

  • Verdict: ✅ Applied in e6d549e — KI is now caught and converted to _VeraExit(130) at every blocking call site. Comment block rewritten to describe this accurately (also flagged by comment-analyzer — same finding from two angles).

Important 2 — tcsetattr failure in finally silently masks the original read error

File: vera/codegen/api.py:1457-1458 (pre-fix)

If tcsetattr itself raises (e.g., fd became invalid mid-call, or stdin was closed by another thread between setraw and tcsetattr), the exception thrown in finally replaces any exception from the try block. So the user loses the original read(1) error and instead sees a tcsetattr error in the Err message, plus their terminal stays in raw mode — invisible, unrecoverable cursor-eating state. This is a textbook silent-mask of one error by another.

  • Verdict: ✅ Applied in e6d549e. Wrapped the tcsetattr call inside the finally in its own try/except Exception: pass so a restore failure doesn't shadow the read error. If restore fails the terminal stays in raw mode (rare; same fd just worked) but at least the read error surfaces. The agent's suggestion of using contextlib.ExitStack would let both failures surface — rejected as over-engineering given how rare double-failure is.

Important 3 — tcgetattr failure path leaves an undefined error message

File: vera/codegen/api.py:1453-1462 (pre-fix)

If termios.tcgetattr(fd) raises (line 1453), the outer except Exception at line 1459 catches it. The error string says "raw-mode read failed: …" — but no raw mode was ever entered, so the message misleads anyone debugging.

  • Verdict: ✅ Applied in e6d549e. Moved tcgetattr outside the outer try, with its own dedicated except reporting tcgetattr failed: <exc>.

Important 4 — Encoding-error masking on sys.stdin.read(1)

File: vera/codegen/api.py:1412, 1456

If stdin's encoding can't decode the next byte (UnicodeDecodeError on a bad-bytes pipe), the except Exception does surface it as Err("stdin.read failed: 'utf-8' codec can't decode…") — that's correct, not silent. But the message doesn't tell the user the byte stream is non-UTF-8, just that "stdin.read failed". An end-user reading the program output wouldn't know to set PYTHONIOENCODING.

  • Verdict: ⏭️ Skipped with reason. The exception text DOES contain the encoding error detail ('utf-8' codec can't decode…), which is enough for a developer to diagnose; adding PYTHONIOENCODING hints to the error message is overengineering for a rare failure mode. A user encountering this once can search for the error text and find standard Python documentation.

Suggestion — Browser stub is the right pattern, not a silent fallback

This is fine. It returns Result.Err with a clear message naming the feature, the cause (JSPI), and tracking issues. Contrast with hostSleep at lines 282-289 which silently busy-waits — that is a hidden fallback (works, but blocks the main thread and misleads users into thinking sleep is cheap). hostReadChar's approach is strictly better than hostSleep's. No change needed.

  • Verdict: ✅ No change needed (explicit positive finding).

Suggestion — Tests don't verify stderr is empty

  • Verdict: ✅ Applied in e6d549e — added assert result.stderr == "" to all three subprocess tests. Without this, a future host that accidentally printed to stderr on the way to a clean exit (e.g. through a KI escape that wasmtime captured) would pass silently. Particularly relevant given finding v0.0.4: Typed AST layer #1 above.

Suggestion — UTF-8 test only exercises the piped path, not the TTY path

Hard to fix portably (no headless PTY harness in the suite). Worth a TODO comment noting the coverage gap.

  • Verdict: ⏭️ Skipped with reason. The new test_io_read_char_stdin_buf_utf8 codegen test (added per pr-test-analyzer's recommendation) covers UTF-8 via the platform-independent stdin_buf path; the TTY raw-mode path remains untestable in CI but is exercised by the conformance test at verify-level and by manual smoke tests.

Strengths

  • Exception (not BaseException) is correctly chosen throughout — KeyboardInterrupt, SystemExit, GeneratorExit all propagate. After this round, the dedicated except KeyboardInterrupt handlers run first (since they're listed before except Exception), so the new KI catches don't break the existing exception precedence.
  • _VeraExit is a direct Exception subclass (line 226) — would be caught by these blocks, but it's never raised inside host_read_char so this is theoretical.
  • Each Err message names the operation that failed (stdin.fileno() failed:, getwch failed:, tcgetattr failed:, raw-mode read failed:, stdin.read failed:) — actionable for debugging.
  • The split try/except for fileno/read/termios is correct — each failure mode gets a distinct error message, no broad catch hides unrelated calls.
  • The stdin_buf test-fixture path is checked first (line 1378) — deterministic test isolation.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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 `@vera/codegen/api.py`:
- Around line 1469-1487: The current read loop uses tty.setraw(fd) which clears
ISIG so Ctrl-C becomes '\x03' and KeyboardInterrupt won't be raised; replace the
raw mode call with cbreak (e.g. use tty.setcbreak(fd)) or explicitly preserve
the ISIG flag on the termios flags before applying the mode so the signal
generation remains enabled, keeping the existing restore logic around
termios.tcsetattr(fd, termios.TCSADRAIN, old) and the outer except
KeyboardInterrupt that raises _VeraExit; update the call site where
tty.setraw(fd) is invoked (and any related comment) so single-character reads
still allow KeyboardInterrupt.
- Around line 1481-1491: The finally block currently swallows errors from
termios.tcsetattr so a failed restore can be hidden; modify the surrounding read
routine to capture any exception raised by termios.tcsetattr(fd,
termios.TCSADRAIN, old) (e.g., store it as restore_exc) instead of silently
passing, then after the try/except that handles the read decide the returned
result: if the read raised an exception prefer returning that error via
_alloc_result_err_string(caller, ...), if the read succeeded but restore_exc is
set return an Err with a message like "raw-mode restore failed: {restore_exc}"
via _alloc_result_err_string, and if both failed prefer the read error; ensure
KeyboardInterrupt still raises _VeraExit(130) as before.
🪄 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.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5a039fb0-587c-4e70-a7f8-e9b71c4af11c

📥 Commits

Reviewing files that changed from the base of the PR and between 9983b2c and e6d549e.

⛔ Files ignored due to path filters (1)
  • tests/conformance/ch07_io_read_char.vera is excluded by !**/*.vera
📒 Files selected for processing (6)
  • CHANGELOG.md
  • ROADMAP.md
  • TESTING.md
  • tests/test_cli.py
  • tests/test_codegen.py
  • vera/codegen/api.py

Comment thread vera/codegen/api.py Outdated
Comment thread vera/codegen/api.py
Two findings, both valid and material.

1. tty.setraw → tty.setcbreak (CRITICAL).  setraw() clears the
   termios ISIG flag, which suppresses SIGINT generation — so
   the `except KeyboardInterrupt` clause added in the previous
   pr-review-toolkit round was actually UNREACHABLE in TTY
   mode.  Ctrl-C would arrive in the read buffer as the literal
   byte `\x03` instead of raising KeyboardInterrupt, meaning a
   Tetris-style game would receive `\x03` as input and the user
   could never exit.

   tty.setcbreak() is the right primitive: disables ICANON +
   ECHO (so we get one character without waiting for Enter and
   without echoing) but PRESERVES ISIG.  Ctrl-C → SIGINT →
   KeyboardInterrupt → _VeraExit(130) works as intended.

   Verified empirically: `cfmakecbreak` keeps the ISIG bit set
   (mode[3] & termios.ISIG != 0), `cfmakeraw` clears it.

2. Capture and surface restore_exc instead of swallowing it.
   The previous round wrapped tcsetattr in an inner finally
   with bare `except Exception: pass`, which kept the original
   read error visible but lost the restore error entirely.
   Now captures the restore exception into a local
   `restore_exc` variable.  Resolution logic:
   - If read failed: return the read error (more actionable).
   - If read succeeded but restore failed: return a distinct
     "raw-mode restore failed: ..." error.
   - If both failed: read error wins.
   - KeyboardInterrupt still raises _VeraExit(130) as before
     (and now this branch is actually reachable).

   No silent failures from this path now.

Also updated:
- The top-of-function comment block to describe cbreak (not
  raw) mode and the ISIG-preservation rationale.
- examples/read_char.vera's prose to mention cbreak vs raw.

Validation: 3,805 pytest pass + 99 browser pytest pass, mypy
clean, all 35 examples + 87 conformance programs pass, all
doc validators green.

Co-Authored-By: Claude <noreply@anthropic.invalid>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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 `@CHANGELOG.md`:
- Line 11: The changelog incorrectly states the Unix TTY uses "termios
raw-mode"; update the Added summary for IO.read_char to reflect the actual
implementation: state that Unix uses termios cbreak mode via tty.setcbreak()
(with raw mode explicitly avoided to preserve ISIG), not raw-mode, so replace
the phrase "termios raw-mode (Unix TTY)" with wording that mentions
cbreak/tty.setcbreak and preservation of ISIG; ensure the rest of the sentence
about Windows/msvcrt.getwch() and piped input remains unchanged.

In `@vera/codegen/api.py`:
- Around line 1493-1519: The raw-mode read path using tty.setcbreak +
sys.stdin.read(1) currently returns the literal Ctrl-D byte as Ok("\x04");
modify the logic in the function handling the read (the block that returns
_alloc_result_ok_string(caller, ch)) to treat the ASCII EOT character '\x04' as
EOF: if ch == '\x04' then return _alloc_result_err_string(caller, "EOF")
(matching the existing EOF handling), otherwise return
_alloc_result_ok_string(caller, ch); keep existing restore_exc checks and
KeyboardInterrupt/_VeraExit behavior unchanged.
🪄 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.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 33fa6386-ef6c-4080-9ca9-5ab687d81a39

📥 Commits

Reviewing files that changed from the base of the PR and between e6d549e and 2bc2cb6.

⛔ Files ignored due to path filters (1)
  • examples/read_char.vera is excluded by !**/*.vera
📒 Files selected for processing (2)
  • CHANGELOG.md
  • vera/codegen/api.py

Comment thread CHANGELOG.md Outdated
Comment thread vera/codegen/api.py
Two final findings, both valid.

1. Unix TTY cbreak now maps `\x04` (Ctrl-D) to EOF (IMPORTANT).
   Without this mapping, a user pressing Ctrl-D in a real-time
   CLI program would get `Ok("\x04")` and have to know to
   interpret the literal byte as end-of-input — surprising and
   platform-specific.  cbreak disables ICANON, which is the
   line discipline layer that normally turns Ctrl-D-at-start-
   of-line into an empty read in canonical mode.  Without
   ICANON, Ctrl-D arrives as a literal `\x04` byte.

   The mapping is restricted to the Unix TTY cbreak branch:
   - Piped `\x04` on the non-TTY shared path stays literal
     (a pipe is a byte stream, the producer chose to include
     `\x04`).
   - Windows `msvcrt.getwch()` branch has its own end-of-input
     convention (Ctrl-Z `\x1A`) — left untouched for now;
     similar mapping could be added if a regression surfaces.

   Added a regression test (test_io_read_char_stdin_buf_passes
   _eot_literally) that pins the non-TTY asymmetry: piped
   `\x04` returns `Ok("\x04")`, not `Err("EOF")`.

2. CHANGELOG wording fix (MINOR).  The Added entry for #618
   still said "termios raw-mode (Unix TTY)" — stale from
   before the round-3 setraw → setcbreak fix.  Updated to
   "termios cbreak mode via `tty.setcbreak()` (cbreak
   preserves ISIG so Ctrl-C still raises SIGINT, unlike raw
   mode which would suppress it)".

Validation: 3,806 pytest pass + 99 browser pass, mypy clean,
ruff S rules clean, all 35 examples + 87 conformance programs
pass, doc counts consistent at 3,936 / 87 / 35.

Co-Authored-By: Claude <noreply@anthropic.invalid>
@aallan aallan merged commit e185940 into main May 19, 2026
23 checks passed
@aallan aallan deleted the feature-618-io-read-char branch May 19, 2026 12:39
@aallan aallan mentioned this pull request May 19, 2026
12 tasks
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.

IO.read_char — single-character input portable across terminal and browser targets

1 participant