Skip to content

FileSink: settle the pending write()'s promise in every synchronous close()/end() flush arm - #35365

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/f1049b3d/filesink-close-orphaned-pending
Jul 24, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/f1049b3d/filesink-close-orphaned-pending

Conversation

@robobun

@robobun robobun commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator

Closes out the bug class #35278 and #35344 started: FileSink::end() (the js_close path behind sink.close()) and FileSink::end_from_js()'s remaining Done/Wrote arms both orphan a backpressured write()'s promise.

Repro

import { createSocketPair } from "bun:internal-for-testing";
import fs from "node:fs";

const [readFd, writeFd] = createSocketPair();
const sink = Bun.file(writeFd).writer();
const writePromise = sink.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); // backpressures
fs.closeSync(readFd);                    // reader gone before the first await
try { sink.close(); } catch {}           // throws EPIPE synchronously on main
await writePromise;                      // never settles on main

The same hang happens on the success path: write a backpressuring chunk, drain the reader synchronously with fs.readSync, then sink.end() (or sink.close()). flush() pushes the remaining buffer through in one shot and returns Done/Wrote, the arm calls writer.end() and returns, and the write's promise is left pending forever.

Cause

All three synchronous arms (Err/Done/Wrote) of FileSink::end(), and the Done/Wrote arms of FileSink::end_from_js(), tear the writer down via writer.end() and return without touching self.pending or scheduling run_pending. writer.end() re-enters on_close synchronously, which fires signal.close(None) and releases the keep-alive ref but never touches the pending slot; IOWriter::flush() doesn't route through parent_on_write for its drain; on_auto_flush short-circuits on done==true or !has_pending_data(). Nothing ever schedules run_pending, so the backpressured write()'s promise stays pending forever. On end()'s Err arm js_close additionally threw the EPIPE at the close() caller.

#35344 fixed end_from_js()'s Err arm; #35278 fixed on_auto_flush. Both left end() entirely and end_from_js()'s Done/Wrote arms unchanged.

Fix

In both end() and end_from_js(), when a backpressured write's promise is outstanding:

  • Err arm (both): latch the error into the pending slot, schedule run_pending_later(), and hand the caller that promise (for end_from_js) / return Ok(()) so js_close doesn't also throw (for end()). FileSink: deliver a failed end() to the pending write's promise instead of double-reporting #35344 already did this for end_from_js(); end() now matches.
  • Done/Wrote arms (both): pending.result already holds Owned(consumed) from to_result; schedule run_pending_later() to deliver it. end_from_js() additionally returns the promise (like its Err/Pending arms) instead of a bare byte count.
  • Pending arm (both): unchanged; the async drain fires on_write, which already settles the slot.

end() returns sys::Result<()> so it can't hand the promise back the way end_from_js does, but routing the outcome to the promise the caller is already meant to be awaiting keeps the one-delivery invariant #35344 established. The other caller of FileSink::end() (subprocess::Writable::close) discards its result, so the Ok(()) doesn't change it, and its pending stdin write now settles where it previously hung. When nothing is pending, end()'s Err-arm throw is unchanged.

Verification

$ git checkout main -- src/ && bun bd test test/js/bun/util/filesink.test.ts \
    -t 'close.. after a backpressured|reader drained returns'
(fail) close() after a backpressured write() with the reader gone ...
  Expected: "EPIPE"
  Received: "close-threw"
(fail) end() after a backpressured write() with the reader drained ...
  Expected: Promise { <pending> }
  Received: 87936

$ git checkout HEAD -- src/ && bun bd test test/js/bun/util/filesink.test.ts
 50 pass
 0 fail

spawn.test.ts -t "EPIPE|stdin", spawn-streaming-stdin.test.ts, spawn-stdin-readable-stream.test.ts, shell/epipe.test.ts, and rust:check-all are green.

Test notes

  • The sink.close() EPIPE test runs in a subprocess with detect_leaks=0 in its env: sink.close() on a Blob-created FileSink leaks the native FileSink on main (${name}__doClose nulls m_sinkPtr before ${name}__close, so ~JSFileSink skips ${name}__finalize and the wrapper's +1 ref is never released). That leak is pre-existing and tracked separately; no test on main exercises sink.close() on a Blob writer.
  • The drained-Done/Wrote test is Linux-only: reaching that arm with one flush() needs the AF_UNIX send buffer to hold the whole remainder after one read cycle (Linux default ~200KB; macOS ~8KB, where flush() returns Pending and the promise was already settled via on_write, so there is nothing to regress).

Flagged by a review comment on closed #35351 (duplicate of merged #35344).


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/util/filesink.test.ts

…ails

FileSink::end() (the js_close path behind sink.close() on the FileSink
prototype and the piped-ReadableStream controller's close()) had the
same WriteResult::Err arm that #35344 fixed in end_from_js(): it set
done=true, tore down the writer, and returned Err without touching the
pending slot. A backpressured write()'s promise was left pending forever
while close() threw EPIPE at its caller.

Mirror end_from_js(): when a backpressured write()'s promise is
outstanding, latch the error into it, schedule run_pending, and return
Ok so js_close doesn't also throw. When nothing is pending the Err
return (and close()'s throw) is unchanged.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b2184e4e-9346-4163-81b3-dc7c5629dad5

📥 Commits

Reviewing files that changed from the base of the PR and between 2bc1598 and ca30716.

📒 Files selected for processing (2)
  • src/runtime/webcore/FileSink.rs
  • test/js/bun/util/filesink.test.ts

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

@robobun

robobun commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:10 AM PT - Jul 24th, 2026

✅ @robobun, your commit ca307169dd3052120b139ceff03ad39d327356e5 passed in Build #79397! 🎉


🧪   To try this PR locally:

bunx bun-pr 35365

That installs a local version of the PR into your bun-35365 executable, so you can run:

bun-35365 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. await writer.end() crashes with ENOSPC instead of throwing a catchable error #24032 - await writer.end() crashes with ENOSPC instead of rejecting the promise; same FileSink::end() error path this PR fixes
  2. The app crashes when writing chunks because there isn't enough disk space #25706 - App crashes when writing chunks with insufficient disk space (ENOSPC during flush); same pattern of unsettled pending write promise in end()

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #24032
Fixes #25706

🤖 Generated with Claude Code

@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 didn't find any bugs. The change mirrors the already-merged end_from_js / on_auto_flush Err arms exactly, and I verified both callers of FileSink::end() (js_close and subprocess::Writable::close) are fine with the Ok(()) return. Deferring only because this is native FileSink teardown code and changes close()'s observable contract (throw → route to pending promise), which is worth a human confirming.

Checked: latch-before-writer.end() ordering matches the sibling paths; self stays live through run_pending_later() (keep-alive ref taken in to_result, wrapper's +1 still held during js_close); the non-pending path is unchanged so close() still throws when no write is outstanding; test fails on unfixed build with the thrown EPIPE and mirrors the sibling end() test.

Extended reasoning...

Overview

Adds a pending-state guard to FileSink::end()'s WriteResult::Err arm (src/runtime/webcore/FileSink.rs:1055): when a backpressured write()'s promise is outstanding, latch the error into self.pending, call writer.end(), schedule run_pending_later(), and return Ok(()) so js_close doesn't also throw. When nothing is pending, behavior is unchanged. Adds a POSIX-only regression test in filesink.test.ts that is a direct sibling of the existing end()-after-backpressure-with-reader-gone test.

Security risks

None. No untrusted input parsing, no new allocation, no new refcount ops. The only new state mutation is writing streams::Writable::Err(e) into an existing JsCell slot.

Level of scrutiny

Medium-high. FileSink teardown is refcount- and re-entrancy-sensitive (the file is dense with SAFETY/provenance commentary), and the change alters the user-visible behavior of sink.close() when a write is pending — it now returns undefined and rejects the outstanding write promise asynchronously instead of throwing EPIPE synchronously. The change itself is small and follows two already-merged sibling fixes (#35344 end_from_js, #35278 on_auto_flush) line-for-line, so the pattern is proven, but a maintainer should confirm the API-contract shift is desired.

Other factors

  • Verified callers: js_close (Sink.rs:655) maps Ok(()) → JSValue::UNDEFINED; subprocess::Writable::close (Writable.rs:534) discards the result with let _ =, so Ok(()) doesn't change it — and it now gets its pending promise settled where previously it was orphaned.
  • Lifetime: when pending.state == Pending, to_result already set must_be_kept_alive_until_eof and took a +1. writer.end() → on_close → clear_keep_alive_ref releases that +1, but the caller (js_close via the C++ wrapper's m_sinkPtr, or the subprocess Writable::Pipe slot) still holds its own ref, so self is live for the subsequent self.run_pending_later(). This is the identical sequence already shipped in end_from_js's Err arm.
  • Test mirrors the sibling end() test precisely (socket pair, 4 MB backpressure, close reader, assert close() returns undefined, await write promise → EPIPE), with the same cleanup pattern. PR description shows it fails on the unfixed build with the thrown EPIPE.

…to a subprocess

The Err arm wasn't the whole bug: end()'s Done and Wrote arms (reached when
close()'s flush drains the buffer synchronously because the reader pulled
between write() and close()) call writer.end() without scheduling run_pending
either, so the write()'s promise hangs the same way. writer.end() only
re-enters on_close, which never touches the pending slot; flush() doesn't
route through on_write. Schedule run_pending_later() in those arms too
(pending.result already holds Owned(consumed) from to_result).

The test now runs in a subprocess with detect_leaks=0 in its env:
sink.close() on a Blob-created FileSink strands the JS wrapper's +1 ref on
main (doClose nulls m_sinkPtr so ~JSFileSink skips finalize), which the
x64-asan lane flags. That leak is pre-existing and tracked separately.
@robobun robobun changed the title FileSink: settle the pending write()'s promise when close()'s flush fails FileSink: settle the pending write()'s promise when close()'s flush drains or fails Jul 24, 2026
Comment thread test/js/bun/util/filesink.test.ts Outdated
Comment thread src/runtime/webcore/FileSink.rs
…rm-fragile drained close() sub-test

end_from_js()'s Done/Wrote arms had the identical orphan (#35344 only fixed
its Err arm). When a backpressured write()'s promise is outstanding they now
hand that promise back (like the Err/Pending arms already do) and schedule
run_pending to resolve it, instead of returning a bare byte count while the
promise hangs.

The close() subprocess test's drained-arm check depended on Linux's ~200KB
AF_UNIX send buffer letting close()'s flush drain the remainder in one go; on
macOS (~8KB) flush() returns Pending and the test would have timed out. Drop
it and cover the Done/Wrote path via an in-process sink.end() test instead,
which is Linux-only for the same reason (on macOS the Pending arm was already
correct) and doesn't hit the pre-existing close() leak.
@robobun robobun changed the title FileSink: settle the pending write()'s promise when close()'s flush drains or fails FileSink: settle the pending write()'s promise in every synchronous close()/end() flush arm Jul 24, 2026

@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.

Beyond the inline nit: 159dbd5 addressed both of my earlier comments — end_from_js()'s Done/Wrote arms now hand back the pending write's promise and schedule run_pending_later(), and the platform-fragile drained close() sub-test was replaced with a Linux-gated in-process sink.end() test. I also checked the detect_leaks=0 override in the new subprocess test — it scopes to the pre-existing sink.close() wrapper-detach leak documented in the test comment, not to anything this fix introduces.

Extended reasoning...

Re-reviewed after 159dbd5. The two substantive issues from the prior run are resolved: end_from_js()'s Done/Wrote arms now mirror the Err/Pending arms (return the outstanding promise, schedule run_pending_later()), and the drained-arm coverage moved to an in-process Linux-only sink.end() test where the AF_UNIX buffer size makes the Done/Wrote path deterministic. In end(), has_pending is read before flush() and in end_from_js() after — checked that IOWriter::flush() never re-enters on_write/run_pending, so the ordering difference doesn't matter. Also confirmed writer.end() in the Done/Wrote arms only re-enters on_close (which never touches self.pending), so grabbing the promise after writer.end() there is safe, unlike the Err arm's before-ordering. The remaining finding is a dead-flag nit; leaving the merge decision to a maintainer given this is FileSink lifecycle/refcount code.

Comment thread test/js/bun/util/filesink.test.ts Outdated

@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.

All three prior findings (macOS drained-arm flake, unfixed end_from_js Done/Wrote arms, dead readFdOpen flag) are addressed and the bug hunter found nothing new this round. Deferring to a human because this is native lifecycle code with a user-visible behavior change to sink.close() (no longer throws when a backpressured write is pending — the error routes to the write's promise instead), and the new subprocess test sets detect_leaks=0 to work around a pre-existing doClose leak that a maintainer should be aware of.

What was reviewed:

  • end()'s new Err/Done/Wrote arms and end_from_js()'s new Done/Wrote arms — ordering of writer.end() vs run_pending_later() matches the merged #35344 Err-arm shape; on_close never touches self.pending, so grabbing the promise after writer.end() is safe.
  • Both callers of FileSink::end() (js_close at Sink.rs:655, subprocess::Writable::close at Writable.rs:534) — the Ok(()) return when pending is harmless for both.
  • has_pending hoisting before/after flush() — flush() is pure I/O per the SAFETY comment and doesn't touch pending.state.
Extended reasoning...

Overview

Completes the FileSink pending-promise bug class started by #35278 and #35344. Touches src/runtime/webcore/FileSink.rs (two functions: end() and end_from_js()) and adds two tests to test/js/bun/util/filesink.test.ts. In end(), all three synchronous flush arms (Err/Done/Wrote) now schedule run_pending_later() when a backpressured write's promise is outstanding, and the Err arm returns Ok(()) instead of propagating the error (which js_close would have thrown, orphaning the promise). In end_from_js(), the Done/Wrote arms now return the outstanding promise (like the Err/Pending arms already did) and schedule run_pending_later().

Security risks

None. No untrusted input parsing, no auth/crypto, no allocation sizing from external data. The unsafe blocks are the same JsCell get_mut() promise-grab pattern already merged in #35344's Err arm, with matching SAFETY comments.

Level of scrutiny

High — native Rust with unsafe, JsCell interior mutability, intrusive refcounting, and re-entrant writer.end() → on_close → clear_keep_alive_ref teardown. I traced the ordering: writer.end() re-enters on_close, which fires signal.close(None) and releases the keep-alive ref but never touches self.pending, so the post-writer.end() promise grab and run_pending_later() are safe. run_pending_later() is idempotent (gated on has). The wrapper's +1 keeps the FileSink alive across the call (same as pre-existing end() Done arm). Both callers of FileSink::end() were checked: js_close returns undefined on Ok(()); subprocess::Writable::close discards the result.

Other factors

This PR went through three prior review rounds where I flagged (1) a macOS-specific test flake in the drained-arm sub-test, (2) the end_from_js Done/Wrote arms being left unfixed, and (3) a dead readFdOpen flag — all addressed in 159dbd5 and ca30716. Two things a maintainer should sign off on: the sink.close() behavior change (silently returning undefined instead of throwing EPIPE when a write is pending — arguably correct since the error now reaches the promise, but it's observable), and the ASAN_OPTIONS: detect_leaks=0 override in the new subprocess test, which papers over a pre-existing doClose/m_sinkPtr leak the PR description says is tracked separately. The Done/Wrote test is Linux-only, which is justified (macOS AF_UNIX buffers are ~8KB so flush() returns Pending there and the arm is unreachable), but leaves that arm without CI coverage on macOS.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Build 79397: 194/196 lanes pass. filesink.test.ts is green everywhere it ran including debian-13-x64-asan (the lane the first revision tripped) and darwin-26-aarch64. The remaining red is darwin-14-aarch64-test-bun: Expired (agent never picked the job up) plus two jobs stuck in scheduled for 2.5h; the five annotated test failures are all retry-passes in unrelated files (webview-chrome, fastutf8stream-reopen, in-process-cron, test-repl-close, 20144). Ready for a maintainer look.

@Jarred-Sumner
Jarred-Sumner merged commit 028f7a3 into main Jul 24, 2026
53 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/f1049b3d/filesink-close-orphaned-pending branch July 24, 2026 10:55
Jarred-Sumner pushed a commit that referenced this pull request Jul 27, 2026
…lose()/end() flush arm (#35365)

Closes out the bug class #35278 and #35344 started: `FileSink::end()`
(the `js_close` path behind `sink.close()`) and
`FileSink::end_from_js()`'s remaining `Done`/`Wrote` arms both orphan a
backpressured `write()`'s promise.

## Repro

```js
import { createSocketPair } from "bun:internal-for-testing";
import fs from "node:fs";

const [readFd, writeFd] = createSocketPair();
const sink = Bun.file(writeFd).writer();
const writePromise = sink.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); // backpressures
fs.closeSync(readFd);                    // reader gone before the first await
try { sink.close(); } catch {}           // throws EPIPE synchronously on main
await writePromise;                      // never settles on main
```

The same hang happens on the success path: write a backpressuring chunk,
drain the reader synchronously with `fs.readSync`, then `sink.end()` (or
`sink.close()`). `flush()` pushes the remaining buffer through in one
shot and returns `Done`/`Wrote`, the arm calls `writer.end()` and
returns, and the write's promise is left pending forever.

## Cause

All three synchronous arms (`Err`/`Done`/`Wrote`) of `FileSink::end()`,
and the `Done`/`Wrote` arms of `FileSink::end_from_js()`, tear the
writer down via `writer.end()` and return without touching
`self.pending` or scheduling `run_pending`. `writer.end()` re-enters
`on_close` synchronously, which fires `signal.close(None)` and releases
the keep-alive ref but never touches the pending slot;
`IOWriter::flush()` doesn't route through `parent_on_write` for its
drain; `on_auto_flush` short-circuits on `done==true` or
`!has_pending_data()`. Nothing ever schedules `run_pending`, so the
backpressured `write()`'s promise stays pending forever. On `end()`'s
Err arm `js_close` additionally threw the EPIPE at the `close()` caller.

#35344 fixed `end_from_js()`'s Err arm; #35278 fixed `on_auto_flush`.
Both left `end()` entirely and `end_from_js()`'s Done/Wrote arms
unchanged.

## Fix

In both `end()` and `end_from_js()`, when a backpressured write's
promise is outstanding:
- **Err arm** (both): latch the error into the pending slot, schedule
`run_pending_later()`, and hand the caller that promise (for
`end_from_js`) / return `Ok(())` so `js_close` doesn't also throw (for
`end()`). #35344 already did this for `end_from_js()`; `end()` now
matches.
- **Done/Wrote arms** (both): `pending.result` already holds
`Owned(consumed)` from `to_result`; schedule `run_pending_later()` to
deliver it. `end_from_js()` additionally returns the promise (like its
Err/Pending arms) instead of a bare byte count.
- **Pending arm** (both): unchanged; the async drain fires `on_write`,
which already settles the slot.

`end()` returns `sys::Result<()>` so it can't hand the promise back the
way `end_from_js` does, but routing the outcome to the promise the
caller is already meant to be awaiting keeps the one-delivery invariant
#35344 established. The other caller of `FileSink::end()`
(`subprocess::Writable::close`) discards its result, so the `Ok(())`
doesn't change it, and its pending stdin write now settles where it
previously hung. When nothing is pending, `end()`'s Err-arm throw is
unchanged.

## Verification

```
$ git checkout main -- src/ && bun bd test test/js/bun/util/filesink.test.ts \
    -t 'close.. after a backpressured|reader drained returns'
(fail) close() after a backpressured write() with the reader gone ...
  Expected: "EPIPE"
  Received: "close-threw"
(fail) end() after a backpressured write() with the reader drained ...
  Expected: Promise { <pending> }
  Received: 87936

$ git checkout HEAD -- src/ && bun bd test test/js/bun/util/filesink.test.ts
 50 pass
 0 fail
```

`spawn.test.ts -t "EPIPE|stdin"`, `spawn-streaming-stdin.test.ts`,
`spawn-stdin-readable-stream.test.ts`, `shell/epipe.test.ts`, and
`rust:check-all` are green.

## Test notes

- The `sink.close()` EPIPE test runs in a subprocess with
`detect_leaks=0` in its env: `sink.close()` on a Blob-created FileSink
leaks the native FileSink on main (`${name}__doClose` nulls `m_sinkPtr`
before `${name}__close`, so `~JSFileSink` skips `${name}__finalize` and
the wrapper's +1 ref is never released). That leak is pre-existing and
tracked separately; no test on main exercises `sink.close()` on a Blob
writer.
- The drained-`Done`/`Wrote` test is Linux-only: reaching that arm with
one `flush()` needs the AF_UNIX send buffer to hold the whole remainder
after one read cycle (Linux default ~200KB; macOS ~8KB, where `flush()`
returns `Pending` and the promise was already settled via `on_write`, so
there is nothing to regress).

Flagged by a review comment on closed #35351 (duplicate of merged
#35344).

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/util/filesink.test.ts

<!-- robobun:evidence:end -->
Jarred-Sumner pushed a commit that referenced this pull request Aug 22, 2026
### Problem
- GitHub closes only the first reference after a keyword, so "Fixes #1,
#2" leaves #2 open. "Supersedes #3" links nothing, and no reference
closes a pull request.
- The last 1000 merged PRs name 274 such references. PR #32292 is open
although merged #36135 says "Supersedes #32292".

### Fix
- `.github/workflows/close-linked-issues.yml` runs on
`pull_request_target` `closed` (a merge into the default branch of
`oven-sh/bun`) and on `workflow_dispatch` with a PR number and
`dry_run`. Everything is inline in one `actions/github-script` step,
with no checkout.
- Each open target is closed as `completed` with the comment "Closed as
completed by #N." or "Superseded by #N.". Closed or missing targets, the
PR itself and other repositories are skipped.
- The parser has no regex. A closing keyword (close, fix, resolve,
supersede, replace, any tense) must lead the reference, alone or in a
list. A negated, hedged or noun keyword, or one whose subject is another
reference, does not count ("may fix", "the rm fix #1", "#100 supersedes
#1").
- Verified: `test/internal/close-linked-issues.test.ts` (333 cases) runs
the YAML's script against fake `github`, `context` and `core`. Also the
1000-PR parse (Notes).

### Background
- GitHub's own keywords are close, fix and resolve (-s, -ed). Each links
one reference, and only a merge into the default branch closes it.
- `pull_request_target` runs in the base repository with a write token,
also for fork PRs. That is safe only when no PR-controlled code runs.
Here the description is the only PR input, parsed as text.

<details><summary>Notes</summary>

A close through the API does not create the "closed this in #N" timeline
link that GitHub makes for its own closes. The comment carries the PR
number instead.

How the parser was calibrated. I pulled the descriptions of the last
1000 merged PRs and listed every line with a keyword next to a
reference. The keyword families, list shapes and reference forms in the
script are the ones that appear there. A reference is `#1`,
`owner/repo#1`, an issue or pull URL (bare or in `<>`), or a markdown
link. Four lines would have been wrong with a plain
keyword-then-reference rule, and each led to a rule:

- "the open `rm` fix #37521" (#38379): "fix" as a noun. Base forms (fix,
close, resolve, supersede, replace) count only at the start of a
sentence or line, or after will, should, does, and, and a few similar
words. "to" is not one of them ("unable to fix #1", "how to fix #1").
- "May also fix #12318 / #10046, untested" (#38242): hedged. may, might,
could, would, partially and the negations disqualify the keyword,
looking past adverbs such as "also".
- "Supersedes the closed #26040" (#36289) and "a comment on closed
#35351" (#35365): "closed" as an adjective. A determiner or preposition
before the keyword disqualifies it.
- "supersedes #33130's optimisation" (#35843): a number that continues
into a word is not a reference.

Review added: a reference before the keyword is the subject ("#100
supersedes #1"), also through "which" or "that" ("reverts #100, which
fixed #1") and across a removed span ("#100 ~~also~~ fixes #1"). A hedge
two words before the keyword disqualifies it ("hopefully this fixes #1",
"could this fix #1?"). A clause that starts with if, when, once, until
or unless is not a statement. The tokenizer keeps a line break as a
token so that "Fixes #1" on one line and "Fixes #2" on the next stay two
statements. Code spans, fences, indented code, blockquotes, HTML
comments and strikethrough are skipped. The block stripping follows
CommonMark for fences (also inside a blockquote), indented code,
blockquotes with lazy continuation, setext underlines and HTML comments,
and GFM for `~~` flanking.

Result over the 1000 descriptions: 274 distinct references in 135 PRs. I
checked the current state of all of them through GraphQL. All but one
are closed (202 issues completed, 5 duplicates, 66 pull requests). The
one open target is PR #32292, superseded by merged #36135. No open
target is a false positive. Every review change kept this result.

Patterns that are deliberately not handled: a bulleted list under
"Closes:" on its own line (not seen in the sample), references separated
by whitespace only ("#1 #2"), "fix for #1", and GH-1 style references. A
`?` after the list is not treated as a question. The block parser tracks
no list containers, so a second paragraph of a list item indented by
four spaces is read as an indented code block and skipped. A removed
span or inline comment reads as one word, so "Fixes <!-- n --> #1" finds
nothing.

The test suite covers: the phrases above, stopping at the right place in
real sentences, CRLF descriptions, URLs with fragments or a `/files`
suffix, case-insensitive `Owner/Repo#1`, the fake API where a lookup, an
update or a comment fails, the `dry_run` input, an invalid `pr_number`
input, an unmerged PR, a PR merged into a non-default branch, the merge
event body against a later edit, and a description with no closing
statement.

The first revision of this PR checked out the repository and ran
`scripts/close-linked-issues.ts`. Jarred asked for no checkout and no
script file, so the script moved inline into the workflow and the test
now reads it out of the YAML.
</details>

<!-- robobun:evidence:begin -->

---

**[stamp-90s]** gate passed · iteration 9 · 2 files touched

<details><summary>passes on PR (with fix)</summary>

```console
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/internal/close-linked-issues.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/internal/close-linked-issues.test.ts
bun test v1.4.1 (4448a2e)

test/internal/close-linked-issues.test.ts:
(pass) finds "Fixes #39852" [176.21ms]
(pass) finds "Closes #31772. Fixes #31771." [22.28ms]
(pass) finds "- Fixes #39930" [12.28ms]
(pass) finds "Fixes: #30429" [10.46ms]
(pass) finds "FIXES #1" [7.86ms]
(pass) finds "(Fixes #1)" [8.97ms]
(pass) finds "**Fixes #1**" [10.20ms]
(pass) finds "__Fixes #1__" [9.83ms]
(pass) finds "_Fixes #1_" [11.25ms]
(pass) finds "Fixes **#1**" [9.72ms]
(pass) finds "**Fixes** #1" [7.13ms]
(pass) finds "**Fixes:** #1" [8.11ms]
(pass) finds "Fixes #1 and **#2**" [11.47ms]
(pass) finds "Fixes **#1**, **#2**" [9.13ms]
(pass) finds "## Why (fixes #13771, closes #30543)" [16.08ms]
(pass) finds "Closes #11418" [19.46ms]
(pass) finds "Resolves #1. Resolved #2. Resolve #3." [12.09ms]
(pass) finds "Fixes #34055, #30327, #24394, #20816, #32403, #11898, #10056." [17.11ms]
(pass) finds "Fixes #18192 and #31675 as a consequence" [10.45ms]
(pass) finds "Fixes #1, #2, and #3" [10.96ms]
(pass) finds "Fixes #1 & #2" [7.63ms]
(pass) finds "Closes #33280,  Closes #32864 and Closes #29696 (the timer in #32949 is orthogonal)" [20.29ms]
(pass) finds "Closes #33182 and #32947 on top of current main (which already has #36304 for catalogs)." [16.12ms]
(pass) finds "Fixes #1,\n#2" [7.76ms]
(pass) finds "Fixes #1, #2,\nand #3" [9.27ms]
(pass) finds "Fixes #1\nand #2" [8.57ms]
(pass) finds "Fixes #1\n& #2" [6.80ms]
(pass) finds "Fixes #1 and\n#2" [7.31ms]
(pass) finds "Supersedes #39908 (same change, moved from a fork branch)" [13.21ms]
(pass) finds "Supersedes #38778 and #38391. Carries the entry point arm of #35053." [14.43ms]
(pass) finds "Supersedes #39193 and keeps its three tests." [11.48ms]
(pass) finds "This supersedes #33306 and #32803. Their tests are kept here." [13.73ms]
(pass) finds "- This replaces #33793. Its 
... (truncated)
Exit: 0
```

</details>

<details><summary>diff hotspot</summary>

```
.github/workflows/close-linked-issues.yml | 950 ++++++++++++++++++++++++++++++
 test/internal/close-linked-issues.test.ts | 598 +++++++++++++++++++
 2 files changed, 1548 insertions(+)
```

</details>

**gate history** · 29 passed · 0 rejected · iteration 9

<details><summary>evidence per changed file</summary>

```
file                                       reads  edits  tests
.github/workflows/close-linked-issues.yml      6     12      0
test/internal/close-linked-issues.test.ts      3     11      0
```

</details>

<!-- robobun:evidence:end -->
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.

3 participants