Skip to content

Bun.markdown.ansi: show image URL and cap Kitty image width - #29131

Closed
robobun wants to merge 12 commits into
mainfrom
farm/92232157/md-image-url-fallback
Closed

robobun wants to merge 12 commits into
mainfrom
farm/92232157/md-image-url-fallback

Conversation

@robobun

@robobun robobun commented Apr 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Closes #29118. Three fallback fixes for how Bun.markdown.ansi() handles images.

1. Show the image URL in the fallback path

Before this change, ![alt](https://example.com/img.jpg) rendered as just 📷 alt with no way to reach the source when OSC 8 hyperlinks were off. Now the URL is shown in dim parens after the alt text, matching the link-fallback style already used by the anchor span:

📷 alt (https://example.com/img.jpg)

Skipped for data: URIs (megabyte base64 payloads would dominate the output), when the image is inside an enclosing link span (the outer link already shows its own URL), and inside table cells / headings (their width machinery would count the URL against cell / underline size).

2. Verify the PNG signature before handing a file to Kitty

emit_image was happily sending any regular file on disk through Kitty's t=f,f=100 (PNG) transmission. For a .jpg/.gif/.webp the terminal would decode the bytes as PNG, fail, and render the broken-image indicator with no caption. Fix: read the PNG signature + IHDR before calling emit_kitty_image_file. Non-PNGs fall through to the URL-label fallback from (1).

3. Scale Kitty image width only when it would overflow

The APC sequence now includes c=<cols> when (and only when) the image's pixel width exceeds what the remaining line can hold at native size. Per the Kitty Graphics Protocol spec, c=<cols> is the exact number of cells to display over — Kitty enlarges images smaller than c cells just as readily as it shrinks larger ones. So emitting c= unconditionally would upscale small icons (favicons, badges) to fill the column budget, a regression vs. pre-PR native-size rendering. The fix is to parse the PNG IHDR and compare width_px against budget * ASSUMED_CELL_PX (16, a conservative upper bound on cell width) — only emit c=<budget> when the image definitely would overflow. c= is also omitted when columns: 0 so wrapping-disabled callers always get native-size rendering.

For the t=d data-URL path the first 24 bytes of raw PNG are decoded out of the base64 payload (~32 base64 chars) to get the IHDR without scanning the whole payload.

API addition

Bun.markdown.ansi(text, theme) now accepts a cwd option to set the base directory used to resolve relative image src paths. Defaults to the process cwd (unchanged behaviour). Added to AnsiTheme in bun.d.ts.

Verification

  • test/regression/issue/29118.test.ts (20 tests) covers: URL in parens, no OSC 8 without hyperlinks:true, c= only for wide images, PNG signature check rejecting JPEG, nested link handling, data URI skip (case-insensitive scheme + MIME), colors-off fallback, data:…;base64, empty payload, inline-image remaining-line budget, table cell / heading guards.
  • test/cli/run/markdown-entrypoint.test.ts (29 tests) passes with the updated snapshot.

Implementation note

The fix lives in the Rust renderer (src/md/ansi_renderer.rs, src/runtime/api/MarkdownObject.rs, src/runtime/cli/run_command.rs). An earlier revision of this branch also edited the parallel .zig porting-reference sources; those files were removed from the tree in #32621, so this branch was rebased and squashed to a single commit dropping the now-deleted .zig edits.

@robobun

robobun commented Apr 10, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 15th, 2026

@robobun, your commit b2fd2e7 is building: #97856

@coderabbitai

coderabbitai Bot commented Apr 10, 2026 •

Copy link
Copy Markdown
Contributor

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

Kitty Graphics emission limited to local PNGs; non-PNG images fall back to alt-text/URL or OSC-8 hyperlinks. Added theme cwd to resolve relative image paths for kittyGraphics. Kitty APC headers may include a column cap to respect available inline width and avoid overflow.

Changes

Cohort / File(s) Summary
Type Definitions
packages/bun-types/bun.d.ts
Clarified Bun.markdown.AnsiTheme docs: kittyGraphics transmits local PNG only; JPEG/GIF/WebP fall back to URL/alt. Added optional cwd?: string to resolve relative image src when kittyGraphics is enabled.
API Implementation
src/bun.js/api/MarkdownObject.zig
Extended renderToAnsi/Bun.markdown.ansi theme shape to accept cwd?; when non-empty string, set theme.image_base_dir from theme.cwd.
Rendering Engine
src/md/ansi_renderer.zig
Emit Kitty APC only for PNG-identified bytes; non-PNGs fall back to existing URL/alt behavior. Centralized data: URI detection and tightened OSC-8 hyperlink fallback conditions. Replaced hardcoded APC header with writeKittyApcHeader(...) and added kittyColumnBudget() to optionally include c=<cols>.
Regression Tests
test/regression/issue/29118.test.ts
New tests for PNG vs non-PNG kitty emission, data-URI handling, hyperlink vs fallback output, APC header c= presence/absence (including columns: 0), and layout edge cases (empty src, nested links, tables, headings).
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: displaying image URLs as fallback and capping Kitty image width. It is concise and specific to the primary objectives.
Linked Issues check ✅ Passed All coding requirements from issue #29118 are addressed: URL fallback for unsupported image formats [#29118], PNG signature verification [#29118], and Kitty image width capping [#29118].
Out of Scope Changes check ✅ Passed All changes are directly aligned with the objectives. Type updates to AnsiTheme, markdown rendering fixes, and comprehensive tests are all in scope for the stated issue.
Description check ✅ Passed The PR description covers the change summary and verification, with enough detail despite not matching the template headings exactly.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/md/ansi_renderer.zig`:
- Around line 1905-1910: The kittyColumnBudget() helper currently returns 0 when
indent >= self.theme.columns which causes writeKittyApcHeader to omit the "c="
width cap; change kittyColumnBudget so that when self.theme.columns > 0 it
returns at least 1 (e.g., return 1 instead of 0 when indent >= columns) so the
"c=" parameter remains present and enforces a minimal cap even in
narrow/deeply-indented layouts; update the function kittyColumnBudget (and any
callers like writeKittyApcHeader if necessary) to rely on a minimum budget of 1
rather than 0.

In `@test/regression/issue/29118.test.ts`:
- Around line 2-12: The leading multi-line issue-summary banner in
test/regression/issue/29118.test.ts (the comment that starts with
"Bun.markdown.ansi() image-rendering improvements:") should be trimmed to a
single-line GitHub issue URL comment on the first line; remove the remaining
explanatory lines in that banner and keep only concise comments that document
test design rationale or specific fixtures/assertions as needed.
- Around line 194-199: The test "image without a src still works (doesn't crash,
doesn't print URL)" currently only rejects the string " ()" which misses other
empty-paren forms; update the assertion to check for bare parentheses by
replacing the negative assertion expect(out).not.toContain(" ()") with
expect(out).not.toContain("()") so the output variable out produced by
Bun.markdown.ansi("![alt]()\n", { colors: true, hyperlinks: false }) is verified
to never contain an empty-URL suffix; keep the existing assertions for "alt"
intact.
🪄 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: f6e7db2f-0a77-425a-b244-1a56d8b1d6e7

📥 Commits

Reviewing files that changed from the base of the PR and between d81b0ca and 32f18abecb3d20f78b2b18d3e24f769fa33a453e.

📒 Files selected for processing (4)
  • packages/bun-types/bun.d.ts
  • src/bun.js/api/MarkdownObject.zig
  • src/md/ansi_renderer.zig
  • test/regression/issue/29118.test.ts

Comment thread src/md/ansi_renderer.zig Resolved
Comment thread test/regression/issue/29118.test.ts Resolved
Comment thread test/regression/issue/29118.test.ts Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
test/regression/issue/29118.test.ts (2)

2-12: ⚠️ Potential issue | 🟡 Minor

Trim the top banner to the single issue URL comment.

Lines 2-12 are mostly issue/PR context prose; keep only Line 1 (// https://github.com/oven-sh/bun/issues/29118) and retain inline comments only where they explain test-signal rationale.

Based on learnings: in test/regression/issue/, the standard is a single-line GitHub issue URL comment; avoid extra inline bug-context prose.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/29118.test.ts` around lines 2 - 12, The top comment
block in test/regression/issue/29118.test.ts contains extra prose; trim it to
the single-line GitHub issue URL comment (“//
https://github.com/oven-sh/bun/issues/29118”) and remove the surrounding
explanatory lines from the banner, preserving only any inline comments that are
directly used as test-signal rationale; locate the header comment at the top of
the file and reduce it accordingly.

199-199: ⚠️ Potential issue | 🟡 Minor

Strengthen the empty-src guard assertion.

Line 199 only rejects " ()". Reject bare () so variants like alt() are also caught.

Proposed fix
-  expect(out).not.toContain(" ()");
+  expect(out).not.toContain("()");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/29118.test.ts` at line 199, The test currently only
rejects the string " ()" but should reject bare parentheses; update the
assertion that checks the output variable out (the expect(out)... line in the
test) to assert not.toContain("()") instead of not.toContain(" ()") so bare "()"
occurrences (e.g., alt()) are caught as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@test/regression/issue/29118.test.ts`:
- Around line 2-12: The top comment block in test/regression/issue/29118.test.ts
contains extra prose; trim it to the single-line GitHub issue URL comment (“//
https://github.com/oven-sh/bun/issues/29118”) and remove the surrounding
explanatory lines from the banner, preserving only any inline comments that are
directly used as test-signal rationale; locate the header comment at the top of
the file and reduce it accordingly.
- Line 199: The test currently only rejects the string " ()" but should reject
bare parentheses; update the assertion that checks the output variable out (the
expect(out)... line in the test) to assert not.toContain("()") instead of
not.toContain(" ()") so bare "()" occurrences (e.g., alt()) are caught as well.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1b38b72f-d69b-4dbf-8da1-d1b4d80eaf45

📥 Commits

Reviewing files that changed from the base of the PR and between 32f18abecb3d20f78b2b18d3e24f769fa33a453e and 002d1d7c5a160a2a13bca904f90d50478bab2f9e.

📒 Files selected for processing (1)
  • test/regression/issue/29118.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/md/ansi_renderer.zig`:
- Around line 1909-1913: The kittyColumnBudget function currently bases the APC
width only on self.theme.columns minus currentIndent(), which ignores visible
text already on the current line and lets images overflow; update
kittyColumnBudget in AnsiRenderer to compute the remaining line width as
theme.columns minus the current column position (not just the indent) — e.g.,
subtract the current cursor/column (use an existing method like currentColumn()
or compute visibleTextWidthOnLine()) in addition to currentIndent(), then clamp
with `@max`(1, remaining) so the returned u32 is at least 1; keep the function
name kittyColumnBudget and the theme.columns usage but replace the single
subtract with a subtraction of the full consumed width.
🪄 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: 6b58422a-ce77-4835-838a-9738c3c71e4e

📥 Commits

Reviewing files that changed from the base of the PR and between 002d1d7c5a160a2a13bca904f90d50478bab2f9e and 3f77240551f65b4bd1c1c24f3be9a8ab34ee7d0a.

📒 Files selected for processing (2)
  • src/md/ansi_renderer.zig
  • test/regression/issue/29118.test.ts

Comment thread src/md/ansi_renderer.zig Resolved
Comment thread src/md/ansi_renderer.zig Resolved
Comment thread src/md/ansi_renderer.zig Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/md/ansi_renderer.zig`:
- Around line 1855-1857: The guard for data: URLs only checks lowercase "data:"
so uppercase/mixed-case URIs still pass; update the is_data_url logic (where
is_data_url is defined using bun.strings.startsWith(src.?, "data:")) to perform
a case-insensitive check by normalizing src (or comparing a lowercased prefix)
before startsWith, and apply the same change to the other instances referenced
around the other block (the section noted around lines 1882-1899) so any
variation like "DATA:" or "Data:" is treated as a data URL and suppressed
consistently.
🪄 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: 2fe1a45a-826d-4b77-8184-ebbab6d109f4

📥 Commits

Reviewing files that changed from the base of the PR and between 3f77240551f65b4bd1c1c24f3be9a8ab34ee7d0a and 0b327a3657453d1121f13732235eddaaa34b1319.

📒 Files selected for processing (2)
  • src/md/ansi_renderer.zig
  • test/regression/issue/29118.test.ts

Comment thread src/md/ansi_renderer.zig Resolved
Comment thread src/md/ansi_renderer.zig Resolved
Comment thread src/md/ansi_renderer.zig Resolved
Comment thread src/md/ansi_renderer.zig Resolved
Comment thread src/md/ansi_renderer.zig Resolved
@robobun
robobun force-pushed the farm/92232157/md-image-url-fallback branch from f5d038c to 073ad9b Compare April 10, 2026 21:53
Comment thread src/md/ansi_renderer.zig Resolved
Comment thread src/md/ansi_renderer.zig Resolved
Comment thread src/md/ansi_renderer.zig Resolved
Comment thread src/md/ansi_renderer.zig Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — all bugs found in prior review rounds have been addressed.

Extended reasoning...

Overview

This PR adds three image-rendering improvements to Bun.markdown.ansi: URL display in the fallback path, PNG signature verification before Kitty transmission, and a column-width cap via c=<cols> in the APC header. It also threads a cwd option through the JS API. Changes touch src/md/ansi_renderer.zig, src/bun.js/api/MarkdownObject.zig, src/cli/run_command.zig, type definitions, and a new regression test file.

Security risks

No security concerns. The code reads local files (PNG signature check) and writes terminal escape sequences. The explicit guards against dumping data: URI payloads (case-insensitive per RFC 3986) prevent accidental megabyte base64 output. No user-controlled input reaches shell execution or SQL.

Level of scrutiny

This is rendering/display code, not production-critical. The main risk surface is subtle state-machine bugs in the ANSI renderer (block stack, last_was_newline, in_cell, heading_level) and edge cases in URI scheme matching. Multiple rounds of review found and fixed exactly these kinds of issues.

Other factors

After several rounds of review, all bugs I flagged were fixed: the in_cell and heading_level guards on the URL-parens fallback, kittyColumnBudget using self.col instead of currentIndent(), all data:/http:/file: scheme checks made case-insensitive, the isPngFile PATH_MAX guard corrected to >=, the empty-payload guard in extractPngDataUrlBase64, and the MIME type/encoding checks made case-insensitive. The only remaining issue from the bug hunter is pre-existing and does not block this PR. 294 lines of new regression tests cover the key paths.

Comment thread src/md/ansi_renderer.zig Resolved
@robobun
robobun force-pushed the farm/92232157/md-image-url-fallback branch from bf5117b to 2835149 Compare May 4, 2026 19:19
Comment thread src/md/ansi_renderer.zig Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No further issues found after 785e70a — the IHDR-gated c= emission resolves the upscaling regression. Deferring to a human for final sign-off since this adds a public API option (cwd on AnsiTheme) and a hardcoded heuristic (ASSUMED_CELL_PX = 16) that are worth a maintainer's eye.

Extended reasoning...

Overview

This PR touches the markdown→ANSI renderer (src/md/ansi_renderer.zig, ~200 lines net), the JS binding (src/runtime/api/MarkdownObject.zig), the CLI prefetch path (src/cli/run_command.zig), the public type surface (packages/bun-types/bun.d.ts), one snapshot, and a 334-line regression test. It introduces several new helpers (readPngDims, parsePngDims, parsePngDimsFromBase64, writeKittyApcHeader, kittyColumnBudget, endsWithCaseInsensitiveAscii) and a new cwd field on the public AnsiTheme interface.

Security risks

Low. The new file-read in readPngDims opens an already-resolved absolute path read-only, peeks 24 bytes, and closes — gated behind the existing resolveLocalImagePath stat check and the user opting into kittyGraphics: true. No shell execution, no network, no path-traversal surface beyond what already existed. The cwd option only feeds image_base_dir, which was already settable internally.

Level of scrutiny

Medium. This is not security- or correctness-critical infrastructure, but it:

  • Adds a documented public API option (cwd), which is a one-way door.
  • Encodes a heuristic constant (ASSUMED_CELL_PX = 16) that determines when images get force-scaled vs. rendered natively — a UX judgment call a maintainer should ratify.
  • Went through ~10 rounds of review-and-fix on this branch (table/heading guards, case-insensitive scheme matching, PATH_MAX off-by-one, empty-payload data URIs, and the Kitty c= exact-vs-max semantics), which signals the change was non-trivial to get right.

Other factors

All prior inline comments (mine and CodeRabbit's) are resolved, the latest bug-hunt pass on 785e70a is clean, and test coverage for the new behavior is thorough (20 targeted assertions including edge cases for tables, headings, inline-prefix images, empty payloads, and case-insensitive MIME). The remaining reason not to auto-approve is scope, not correctness.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the 785e70a fix — the IHDR-gated c= emission looks right. No further issues from me, but deferring to a human for the new cwd API option and the ASSUMED_CELL_PX = 16 heuristic, since both are judgment calls worth a second pair of eyes.

Extended reasoning...

Overview

Touches src/md/ansi_renderer.zig (~200 new lines: PNG signature + IHDR parsing, partial base64 decode, conditional Kitty c= emission, URL-parens fallback with in_cell/heading/link_depth/data-URI guards), src/runtime/api/MarkdownObject.zig (new cwd theme option), src/cli/run_command.zig (case-insensitive http(s) scheme), bun.d.ts, a snapshot update, and a 334-line regression test.

Security risks

None identified. readPngDims opens user-supplied paths read-only, but only behind an explicit kittyGraphics: true opt-in and after the pre-existing resolveLocalImagePath stat() gate. No injection, auth, or data-exposure surface.

Level of scrutiny

Medium. This is a rendering/UX feature, not a hot path or security boundary, but it went through ~10 review iterations with real bugs caught each round (table-cell guard, heading guard, self.col vs indent, four separate case-insensitivity gaps, MAX_PATH_BYTES off-by-one, empty-payload APC, and the c= upscaling regression). The most recent fix (785e70a) added a meaningful chunk of new logic — parsePngDims, readPngDims, parsePngDimsFromBase64, and the ASSUMED_CELL_PX threshold — in response to my last comment, and that code hasn't had independent review yet.

Other factors

Two design decisions warrant human sign-off: (1) the new public cwd?: string field on AnsiTheme, and (2) ASSUMED_CELL_PX = 16 — the comment notes real cells are 6–12 px, so images in the budget*6 .. budget*16 px range will still render at native size and may overflow on narrow-cell fonts; that's a deliberate under-cap tradeoff someone should ack. CI is green except for an unrelated Windows http-close flake. All prior reviewer comments (mine and CodeRabbit's) are resolved.

Comment thread src/runtime/api/MarkdownObject.zig Resolved
@robobun
robobun force-pushed the farm/92232157/md-image-url-fallback branch from c17e715 to 98d246d Compare May 15, 2026 01:15

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new issues found and all prior review feedback has been addressed — deferring to a human for final sign-off on the new cwd API option and the ASSUMED_CELL_PX scaling heuristic.

Extended reasoning...

Overview

This PR touches six files: ~250 lines of logic in src/md/ansi_renderer.zig (PNG IHDR parsing, Kitty APC header emission with conditional c= scaling, URL-parens fallback for images, case-insensitive URI scheme handling), ~50 lines in src/runtime/api/MarkdownObject.zig (new cwd theme option with relative-path absolutization), a small case-insensitivity fix in src/runtime/cli/run_command.zig, the AnsiTheme type definition in bun.d.ts, one snapshot update, and a 360-line regression test suite.

Security risks

None identified. The new file I/O (readPngDims) opens files read-only with a length-guarded stack buffer and only reads 24 bytes; the path comes from markdown the caller already chose to render. The cwd option is absolutized before use and only feeds into stat()/open() for read — no write, exec, or traversal beyond what the existing resolveLocalImagePath already did with process cwd.

Level of scrutiny

Medium. This is not a simple/mechanical change: it adds a new public API field (AnsiTheme.cwd), introduces binary parsing of PNG headers and base64 prefixes, and encodes a heuristic (ASSUMED_CELL_PX = 16) that governs when Kitty images get downscaled vs. rendered native-size. The fallback-rendering guards (in_cell, heading_level, link_depth, is_data_url) interact with several layout subsystems.

Other factors

The PR has been through ~10 rounds of automated review; every flagged issue (in_cell/heading guards, self.col vs indent, case-insensitive scheme/MIME, empty-payload data URI, MAX_PATH_BYTES off-by-one, Kitty c= upscaling semantics, relative-cwd absolutization) was fixed and has a corresponding regression test. The current bug-hunting pass found nothing. CI has been retriggered for unrelated infra flakes only. Given the new public API surface and the scaling heuristic being a judgment call, a human should give the final approval rather than auto-merging.

@robobun

robobun commented May 15, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is green — the only red on the latest run (#54503) is two SIGKILLs on the same Ubuntu 25.04 x64 runner (test/js/node/http/node-http-backpressure.test.ts and test/js/bun/util/v8-heap-snapshot.test.ts) plus a Windows mprotect failed: 487 on jsc-stress. None of those touch markdown, Kitty graphics, image parsing, or anything in this diff — looks like runner memory pressure. My 20-test regression suite (test/regression/issue/29118.test.ts) passes on every lane it ran on.

Ready for a maintainer to merge or kick CI once the infra settles.

@robobun
robobun force-pushed the farm/92232157/md-image-url-fallback branch from fc874f7 to 6f963c8 Compare May 21, 2026 09:46
Comment thread src/codegen/bake-codegen.ts Resolved
@robobun

robobun commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

CI build #56618 has one darwin-14-aarch64-test-bun that expired (agent timeout, 2 retries — infrastructure issue, not a test-level failure). Darwin 26 aarch64 and Darwin 14 x64 both passed, every other lane passed. Only ERROR annotations are empty; the single WARNING is a flaky test/bake/dev-and-prod.test.ts on Windows 2019 x64-baseline which already retried and passed.

Diff-relevant lanes are green. Ready for a maintainer to merge or kick the one Darwin 14 aarch64 lane.

@robobun
robobun force-pushed the farm/92232157/md-image-url-fallback branch from 8e0d295 to 9cb2eb7 Compare May 25, 2026 21:03
Comment thread src/md/ansi_renderer.rs Resolved
Comment thread src/md/ansi_renderer.rs Resolved
Comment thread src/md/ansi_renderer.rs Resolved
Comment thread src/runtime/api/MarkdownObject.rs Resolved
Comment thread src/runtime/api/MarkdownObject.rs Resolved
Comment thread src/runtime/api/MarkdownObject.rs Resolved
Comment thread src/runtime/api/MarkdownObject.rs Resolved
Comment thread src/md/ansi_renderer.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/md/ansi_renderer.rs:2930-2932 — The explicit let _ = file.close(); and its comment are redundant/misleading: bun_sys::File already closes on Drop (src/sys/file.rs:17-24, documented in src/CLAUDE.md), so the fd is closed on every path out regardless. File::close(self)'s only documented benefit over Drop is that "the syscall result is observable" — but let _ = discards that. Just delete both lines and let RAII do it (matching src/CLAUDE.md's own idiom // file closes on Drop.); at minimum drop the comment, since "we must be sure it runs on every path out" wrongly implies Drop doesn't cover it.

    Extended reasoning...

    What the issue is

    read_png_dims (src/md/ansi_renderer.rs:2930-2932) contains:

    let read_result = file.read_all(&mut buf);
    // Always close — File::close consumes self, so we must be sure it runs
    // on every path out.
    let _ = file.close();
    match read_result {
        bun_sys::Result::Ok(amt) if amt >= 24 => parse_png_dims(&buf),
        _ => None,
    }

    The comment claims the explicit .close() is needed to ensure the fd is released "on every path out." That is factually wrong for bun_sys::File, and the explicit call gains nothing because its return is discarded.

    Why the comment is wrong

    bun_sys::File owns its fd and closes it in Drop:

    // src/sys/file.rs:17-24
    impl Drop for File {
        #[inline]
        fn drop(&mut self) {
            if self.handle != Fd::INVALID && !self.handle.is_stdio() {
                let _ = close(self.handle);
            }
        }
    }

    This is documented at src/CLAUDE.md — bun_sys::File (owns the fd; closes on Drop) — and the crate's own canonical usage example there ends with the comment // file closes on Drop. with no explicit close call. The project convention (CLAUDE.md rule 8) is explicit: "Prefer RAII (Drop) over manual cleanup."

    Why the explicit .close() gains nothing here

    File::close(self) (src/sys/file.rs:313-321) is documented as "Close now. Equivalent to dropping self but the syscall result is observable." Its sole distinguishing feature is that it returns the close(2) result — but this call site immediately discards it with let _ =. So the ONLY reason the API exists to prefer .close() over Drop is thrown away. The match on line 2933 is the last statement in the function, so file would drop at exactly the same point in execution anyway; there is no timing benefit either. And there is no double-close risk (.close() consumes self via into_raw(), disarming Drop), so the call is harmless — it is just dead weight.

    Why the comment is the actionable problem

    REVIEW.md: "Only comment what the code cannot say. One line. Never restate what the code does." This comment does worse than restate — it misstates. A reader coming to this file learns "bun_sys::File doesn't close on Drop, you must manually ensure .close() on every path" — the opposite of the truth. That is exactly the kind of misdirection REVIEW.md's "verify semantics empirically, never from names or intuition" rule tries to prevent readers from having to do. Existing callers elsewhere in the tree (e.g. src/install/migration.rs:44 carries // file closes on Drop) rely on the RAII behavior, so this comment also disagrees with established convention.

    Step-by-step proof the fd closes without the explicit call

    Delete lines 2930-2932. Trace both paths out of the match:

    1. bun_sys::Result::Ok(amt) if amt >= 24 → parse_png_dims(&buf) returns; file (a local owned by this stack frame) goes out of scope; Drop::drop runs; self.handle != Fd::INVALID (open just succeeded) and !is_stdio() (a regular file we opened) → close(self.handle) fires.
    2. _ => None → identical: file goes out of scope at the closing brace; same Drop path.
    3. Early returns before file exists (lines 2917, 2923) never opened an fd.

    There is no path on which the fd survives function return.

    Impact

    None functionally — no fd leak, no double-close, no UB. This is purely comment-accuracy + a redundant call on new code introduced by this PR, hence nit.

    Fix

    Delete both lines (comment + let _ = file.close();) and let file drop at end-of-scope — matching src/CLAUDE.md's own example. If keeping the explicit close is preferred for symmetry with some other site, at minimum delete the two-line comment: it is what actively misleads.

  • 🟡 src/md/ansi_renderer.rs:2115-2142 — format_c_hint hand-rolls a 25-line BufWriter struct + core::fmt::Write impl to format one u32 into a stack buffer, but bun_core::fmt::itoa(&mut ItoaBuf, n) -> &[u8] already does exactly this and is what the sibling renderer uses (src/md/html_renderer.rs:597, src/md/helpers.rs:476). The whole helper collapses to self.write_raw_no_color(b",c="); self.write_raw_no_color(bun_core::fmt::itoa(&mut bun_core::fmt::ItoaBuf::new(), budget)); inlined at the single call site in write_kitty_apc_header, which also drops the let _ = write!(...) discarded-result.

    Extended reasoning...

    What the issue is

    format_c_hint (src/md/ansi_renderer.rs:2118-2143) defines an inline BufWriter<'a> struct with a core::fmt::Write impl, constructs it over a caller-provided [u8; 16], calls write!(w, ",c={}", budget) with the result discarded via let _ =, and returns the written slice. This is 25 lines of bespoke machinery whose only job is to format a single u32 into a small stack buffer.

    The codebase already has a helper for exactly this: bun_core::fmt::itoa(&mut ItoaBuf, n) -> &[u8] (src/bun_core/fmt.rs:3203, with ItoaBuf re-exported at :3198). It's the tree-wide convention for base-10 integer formatting into a stack buffer, used at 15+ call sites — including two in the same crate: src/md/html_renderer.rs:597 (self.write(bun_core::fmt::itoa(&mut buf, value))) and src/md/helpers.rs:476. So the sibling HTML renderer already does this the canonical way; format_c_hint is the odd one out.

    Why REVIEW.md flags this

    REVIEW.md's Code style & idioms section is explicit: "In runtime native code, grep for the in-tree helper before hand-writing anything. File I/O, paths, strings, hashing, formatting … Being the only file touching a raw primitive is a red flag." Grep confirms this is the only place in the tree hand-rolling a core::fmt::Write impl to format an integer into a stack buffer. src/CLAUDE.md also lists bun_core::fmt alongside bun_core::strings as the canonical toolkit. Additionally, bun_alloc::SliceCursor already provides a core::fmt::Write impl over &mut [u8], so even the write!-based approach wouldn't need a hand-rolled struct.

    Why this is a nit, not normal

    The code is functionally correct: u32::MAX is 10 digits, plus ",c=" = 13 bytes < 16, so the buffer never overflows and the discarded write! result is always Ok. There is no user-visible behavior difference. This is purely a convention/code-quality finding on new code introduced by this PR — merging as-is causes no failure, so per the severity rubric it does not justify blocking merge.

    How to fix

    Delete format_c_hint entirely and inline at the single call site in write_kitty_apc_header (line 2107-2111):

    if budget > 0 && width_px > budget.saturating_mul(ASSUMED_CELL_PX) {
        self.write_raw_no_color(b",c=");
        let mut buf = bun_core::fmt::ItoaBuf::new();
        self.write_raw_no_color(bun_core::fmt::itoa(&mut buf, budget));
    }

    This drops ~25 lines, removes the discarded-result pattern, and matches the sibling renderer at html_renderer.rs:595-598 exactly.

    Step-by-step proof

    Input: [](./wide.png) with { kittyGraphics: true, columns: 40 } and a 2000px-wide PNG.

    1. write_kitty_apc_header(b"t=f", 2000) is called; budget = 40, 2000 > 40*16 = 640, so the c= branch runs.
    2. Current code: allocates [0u8; 16] on the stack, constructs BufWriter { buf, pos: 0 }, dispatches through the core::fmt::Write vtable via write!(w, ",c={}", 40) → w.write_str(",c=") copies 3 bytes, then core::fmt's integer formatter calls w.write_str("40") copies 2 bytes; pos = 5; returns &buf[..5] = b",c=40"; caller writes it via write_raw_no_color.
    3. Proposed code: write_raw_no_color(b",c=") writes 3 bytes; ItoaBuf::new() is a const uninit 40-byte array; itoa(&mut buf, 40u32) returns b"40"; write_raw_no_color writes 2 bytes.
    4. Output is byte-identical: \x1b_Ga=T,t=f,f=100,q=2,c=40;. The test at test/regression/issue/29118.test.ts:159 (expect(out).toContain("\x1b_Ga=T,t=f,f=100,q=2,c=40;")) and the c=30 inline-prefix test at :291 continue to pass unchanged.

format_c_hint hand-rolled a core::fmt::Write buffer when
bun_core::fmt::itoa is the in-tree helper (html_renderer already uses
it). bun_sys::File closes on Drop, so the explicit close discarding
its result added nothing.
Comment thread src/md/ansi_renderer.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/md/ansi_renderer.rs:2073-2097 — format_c_hint hand-rolls a 26-line core::fmt::Write impl over a stack buffer to format ,c=<u32> — but this file already uses bun_core::fmt::buf_print (line 380) for exactly this, and sibling md files use bun_core::fmt::itoa. The whole helper collapses to bun_core::fmt::buf_print(buf, format_args!(",c={budget}")) or two write_raw_no_color calls (b",c=" + itoa) inline in write_kitty_apc_header.

    Extended reasoning...

    What the issue is

    The new format_c_hint helper (src/md/ansi_renderer.rs:2073-2097) defines a bespoke local BufWriter struct with a hand-written core::fmt::Write impl, then calls write!(w, ",c={}", budget) — 26 lines total — to format one u32 into a 16-byte stack buffer:

    fn format_c_hint(buf: &mut [u8; 16], budget: u32) -> &[u8] {
        use core::fmt::Write as _;
        struct BufWriter<'a> { buf: &'a mut [u8], pos: usize }
        impl<'a> core::fmt::Write for BufWriter<'a> {
            fn write_str(&mut self, s: &str) -> core::fmt::Result { ... }
        }
        let mut w = BufWriter { buf: &mut buf[..], pos: 0 };
        let _ = write!(w, ",c={}", budget);
        &buf[..w.pos]
    }

    Why this diverges from convention

    REVIEW.md is explicit: "grep for the in-tree helper before hand-writing anything … bun_core strings/fmt/Output … Being the only file touching a raw primitive is a red flag" and "Match the exact file's local conventions." Both apply here:

    1. This same file already uses the in-tree helper. ansi_renderer.rs:380 does bun_core::fmt::buf_print(&mut num_buf, format_args!("{num}. ")) for the ordered-list marker — a SliceCursor-based core::fmt::Write over a caller stack buffer, i.e. precisely what BufWriter reinvents.
    2. Sibling files in the same crate use itoa for u32→ASCII. src/md/html_renderer.rs:596-597 has let mut buf = bun_core::fmt::ItoaBuf::new(); self.write(bun_core::fmt::itoa(&mut buf, value)); and src/md/helpers.rs:475-476 does the same. bun_core::fmt::itoa (fmt.rs:3203) is the documented core::fmt-free integer-to-ASCII path.

    So format_c_hint is new code that hand-rolls a helper the file itself already imports and uses ~1700 lines above, and that the crate uses in two sibling files.

    How to fix

    Either delete format_c_hint entirely and inline into write_kitty_apc_header:

    if budget > 0 && width_px > budget.saturating_mul(ASSUMED_CELL_PX) {
        self.write_raw_no_color(b",c=");
        let mut buf = bun_core::fmt::ItoaBuf::new();
        self.write_raw_no_color(bun_core::fmt::itoa(&mut buf, budget));
    }

    …or, if you want to keep the single-write shape, replace the body with the same helper line 380 uses:

    let s = bun_core::fmt::buf_print(&mut buf[..], format_args!(",c={budget}")).unwrap_or(b"");
    self.write_raw_no_color(s);

    Both drop the bespoke BufWriter struct + core::fmt::Write impl and align with the file-family convention.

    Impact / why this is a nit

    There is no functional bug. The hand-rolled version is correct: 16 bytes comfortably fits ",c=" (3) + u32::MAX (10 digits) = 13 bytes max, so the Err(core::fmt::Error) branch is unreachable and the discarded write! result is fine. Output is byte-identical to what buf_print/itoa would produce. This is purely a code-quality / convention finding on new code introduced by the PR — nice to fix but not merge-blocking.

    Step-by-step proof

    Take budget = 40:

    1. Current: BufWriter::write_str receives ",c=" then "40", copies to buf[0..3] and buf[3..5], returns &buf[..5] = b",c=40".
    2. bun_core::fmt::buf_print(&mut buf, format_args!(",c={}", 40)): internal SliceCursor receives the same two fragments, writes to buf[0..5], returns Ok(&buf[..5]) = b",c=40".
    3. itoa variant: writes b",c=" then itoa(&mut ItoaBuf::new(), 40) = b"40" — same bytes on the wire.

    All three produce identical APC output; the only difference is 26 lines of bespoke type vs. one line calling the documented in-tree helper the file already uses.

Comment thread test/js/bun/md/md-image.test.ts Resolved
Comment thread src/md/ansi_renderer.rs Resolved
Comment thread src/md/ansi_renderer.rs Resolved
Comment thread src/runtime/cli/run_command.rs Resolved
Comment thread test/js/bun/md/md-image.test.ts Resolved
- Move the issue-29118 tests to test/js/bun/md/md-image.test.ts: the
  fixes are first-time behavior and cwd is a new option, so the
  regression directory rule does not apply.
- Reword the stale threadlocal-buffer comment in
  resolve_local_image_path (the checked join writes a stack buffer).
- Use bare Ok/Err arms and the strings:: alias to match file
  conventions.
Comment thread src/md/ansi_renderer.rs Resolved
Comment thread src/runtime/api/MarkdownObject.rs Resolved
PathBuffer is ~96 KB on Windows, and the new cwd_abs_buf/proc_cwd_buf
pair plus the join and IHDR-read buffers were unconditional stack
reservations in a hot host-fn frame. Swap the four new buffers to
bun_paths::path_buffer_pool per src/CLAUDE.md.
Comment thread src/md/ansi_renderer.rs Resolved
Comment thread src/md/ansi_renderer.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed this revision and the automated bug-hunt found no issues — all prior nits (path-buffer pool, bare Ok/Err arms, strings:: alias, test-file placement, stderr assertions, and the pooled-scratch-buffer comment wording) are addressed. Because the PR adds new public API surface (cwd on AnsiTheme) and ~250 lines of renderer/file-I/O logic (PNG IHDR parsing, the ASSUMED_CELL_PX=16 scaling heuristic, path-buffer join guards), a maintainer sign-off on those design choices would still be worthwhile.

What was reviewed:

  • URL-parens fallback: checked the in_cell / heading_level / link / is_data_url guards match the sibling link-fallback and Kitty-gate conditions; snapshot update is consistent.
  • read_png_dims / parse_png_dims_from_base64: bounds are checked before slicing; bun_sys::File closes on Drop; over-long paths return None before resolve_path::z.
  • cwd handling in MarkdownObject.rs: to_slice result is copied into an owned Vec/pooled buffer before render, so no borrow outlives the JS string; over-long input degrades to the process-cwd fallback via _checked join.
  • Case-insensitive scheme/MIME checks applied consistently across emitImage, resolve_local_image_path, extract_png_data_url_base64, and the CLI prefetch filter.
Extended reasoning...

Overview

This PR touches src/md/ansi_renderer.rs (~200 LOC: URL-parens fallback for images, PNG signature/IHDR parsing, Kitty c= width capping, case-insensitive scheme checks, checked path-buffer joins), src/runtime/api/MarkdownObject.rs (~50 LOC: new cwd option with absolute/relative resolution and overflow guards), a 3-line case-insensitivity fix in src/runtime/cli/run_command.rs, the cwd/PNG-only doc update in bun.d.ts, a new 421-line test/js/bun/md/md-image.test.ts, two assertion updates in md-edge-cases.test.ts, and one snapshot update.

Security risks

Low. The renderer already resolved and stat'd local image paths pre-PR; this change adds a 24-byte read of the resolved file (PNG header sniff) via bun_sys::File::open(O_RDONLY), gated behind the existing kittyGraphics: true opt-in. No new network I/O. The URL-parens fallback writes document-sourced bytes through write_styled, which goes through the same control-byte sanitization path already covered by the "link and metadata control bytes" tests in md-edge-cases.test.ts. Path handling uses the checked join variant and length-guards the fixed-size PathBuffer before resolve_path::z, with subprocess crash-guard tests for over-long cwd/src.

Level of scrutiny

Medium-high. This is not a mechanical change: it introduces a new public API option (cwd), a heuristic constant (ASSUMED_CELL_PX = 16), and product-visible fallback formatting decisions (skip URL in tables/headings, PNG-only Kitty). Those are design calls a maintainer should confirm, which is why I'm deferring rather than approving.

Other factors

The PR has been through many review rounds since April; every substantive finding (missing in_cell guard, path-buffer overflow, stack PathBuffer on Windows) and every recent nit has a corresponding fix commit, and the current diff reflects all of them — including my most recent note (b2fd2e7 rewords the join comment to "pooled scratch buffer"). The two open github-actions comment-cop flags are the automated heuristic firing on a 3-line factual comment, not a workaround justification. Test coverage is thorough (20 targeted tests plus the 3 subprocess crash guards, all following harness conventions), and the existing markdown-entrypoint snapshot was updated to match the new fallback.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Current state on build #97856 (tip b2fd2e7): every test lane passed except darwin-14-aarch64-test-bun, which errored at the agent level after 6 retries (same chronic infra lane that expired on earlier runs). There are no test failures attributable to this diff; the only warnings are unrelated flaky tests that passed when re-run alone.

Review is fully converged: the latest automated review pass found no issues and all review threads are resolved. Ready for a maintainer to merge or re-kick the one Darwin lane.

@github-actions

Copy link
Copy Markdown
Contributor

Closing this PR because it has been inactive for more than 90 days.

@github-actions github-actions Bot closed this Nov 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improvements for Bun.markdown.ansi kittyGraphics supports

1 participant