Bun.openInEditor: throw when no editor is found instead of spawning "" - #37210
Conversation
`EditorContext::detect_editor` records `Editor::None` once it has looked everywhere and found nothing, so the caller's `is_none()` checks never fired: an unknown `editor` name (or a failed auto-detect) fell through to `Editor::open` with an empty binary path and spawned `argv[0] = ""` on a detached thread, and the "Could not find editor" / "Failed to auto-detect editor" errors were unreachable. Treat `Editor::None` as not found at those checks. The re-entrancy test relied on those calls being inert (empty PATH, no EDITOR/VISUAL); they were actually starting eight editor threads per run. It now asserts the errors.
|
@robobun adopt |
|
Adopted. Verified the bug with the current canary (test prints "opened" x4, the silent spawn of an empty argv[0]). Building and running the branch test now, will take it from here. |
Walkthrough
ChangesEditor detection flow
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
LGTM — straightforward fix making the two "editor not found" error paths in Bun.openInEditor actually reachable.
What was reviewed:
- Traced
detect_editor→ confirmed it storesSome(Editor::None)as a search-done sentinel, so the oldis_none()checks at both throw sites were dead;found()correctly filters the sentinel. - Walked all four test variants through the re-entrancy path (inner
reenter()→ restore → outer call) and each produces the asserted message; the re-entrancy invariant from #36920 is still exercised (a panic onborrow_mut()would fail the test). - Checked other
EditorContext/.editorconsumers (repl.rs,repl_command.rs,bunfig.rs) — none touched,found()is only called fromopen_in_editor.
Extended reasoning...
Overview
Three-file change: a 3-line EditorContext::found() helper in src/runtime/cli/open.rs that filters the Editor::None sentinel out of Option<Editor>; three call-site swaps in open_in_editor (src/runtime/api/BunObject.rs) from edit.editor to edit.found() plus .or → .or_else; and a test update that replaces four swallowed-catch "ok" assertions with the exact error messages.
Security risks
None. This narrows behavior — a call that previously silently spawned a detached thread with argv[0] = "" now throws before spawning. No new user input reaches any syscall; the only new code path is an early return Err(throw(...)).
Level of scrutiny
Low-to-medium. Bun.openInEditor is a developer-convenience utility, not a hot path or a security boundary. The fix is a sentinel-interpretation correction at three call sites, backed by a helper on the type that owns the sentinel's meaning. The .or → .or_else change is behaviorally identical (found() is a pure read). I traced every branch of open_in_editor after the change: the named-editor path now throws-and-restores on failure (previously the restore was dead too), the cached-name path falls through to edit.found() correctly, and the auto-detect path throws on Some(Editor::None) whether freshly detected or cached from a prior call.
Other factors
- The updated test is strictly stronger: it keeps the re-entrancy coverage (getters/
toStringstill callBun.openInEditorrecursively before theRefCellborrow) and now asserts exact error text for both throw sites instead ofcatch {}. Verified the four expected lines by hand-tracing state across thereenter()calls and the*edit = prevrestore. found()ispub(crate)and only called fromopen_in_editor; grepped for otherEditorContextconsumers and none read.editorin a way this affects.- PR description is candid about what was and was not reproduced (the CI hang), and the claimed verification (updated test fails on canary with
"opened" ×4, passes on the branch) matches what the diff would produce.
There was a problem hiding this comment.
LGTM — small, well-scoped fix that makes the two "editor not found" error paths in Bun.openInEditor actually reachable.
What was reviewed:
- Traced
detect_editor→ confirmed it terminates withSome(Editor::None)as a "searched, found nothing" sentinel, so the oldis_none()checks inopen_in_editorwere unreachable and control fell through toEditor::open("", …). - Checked all
EditorContextconsumers (onlyBunObject.rsandopen.rsitself) — no other site reads.editordirectly and needs the same treatment;auto_detect_editorcorrectly still gates on raw.is_none()to preserve caching. - Walked the four test variants against the state-restore logic (
*edit = prevon the explicit-name failure path, no restore on the auto-detect path) — the asserted message sequence matches, and re-entrancy coverage from #36920 is preserved since the getters still callBun.openInEditorbefore the RefCell borrow.
Extended reasoning...
Overview
Adds EditorContext::found() (filters the Some(Editor::None) sentinel to None) and swaps three edit.editor reads in open_in_editor for it. detect_editor writes Some(Editor::None) when the search comes up empty so it is not repeated; the old is_none() checks therefore never fired and the code fell through to spawning a detached thread with argv[0] = "". The updated test asserts the four now-reachable error messages instead of swallowing them, and the PR description ties this to a flaky Linux CI test that was unintentionally spawning eight of those threads per run.
Security risks
None. Bun.openInEditor is a developer utility; the change only adds early-exit error paths and removes an unintended spawn_sync(""). No new input parsing, no memory or lifetime changes.
Level of scrutiny
Low. Three-line helper plus three call-site swaps in a non-critical path. The .or(edit.editor) → .or_else(|| edit.found()) change is semantically the intended fix (and lazily evaluated, which is fine). I confirmed via grep that EditorContext has no other consumers in the repo, so there is no sibling site left un-fixed. auto_detect_editor intentionally keeps its raw self.editor.is_none() guard — that is the cache check, and switching it to found() would defeat the "do not search again" behavior the sentinel exists for.
Other factors
The test still exercises the re-entrancy fix from #36920 (getters call Bun.openInEditor during option coercion, before the EDITOR_CONTEXT RefCell borrow), and now additionally asserts exact error messages — a strictly stronger invariant than the previous "ok" ×4. I traced the *edit = prev restore on the explicit-editor failure path and the lack of restore on the auto-detect path against the four variants; the expected output sequence (Could not find editor … ×2, Failed to auto-detect editor ×2) is correct given empty PATH and unset EDITOR/VISUAL. The test remains Linux-gated for the same reason as before (macOS probes /Applications).
oven-sh#37210) ### What does this PR do? `Bun.openInEditor(file, { editor: "does-not-exist" })` — or a call with no editor when nothing can be auto-detected — was meant to throw `Could not find editor "…"` / `Failed to auto-detect editor`, but never did. `EditorContext::detect_editor` stores `Some(Editor::None)` once it has looked everywhere (name, `EDITOR`/`VISUAL`, `PATH`, fallback app paths) and found nothing, so it doesn't search again; the two `is_none()` checks in `open_in_editor` therefore never fired and the call fell through to `Editor::open` with an empty binary path, which starts a detached thread that `spawn_sync`s `argv[0] = ""` and swallows the failure. From JS it looked like success. This treats `Editor::None` as "not found" at those checks (`EditorContext::found()`), making both errors reachable; nothing is spawned. Why now: `test/js/bun/util/open-in-editor-gc.test.ts` › "survives re-entrant calls from option getters" (added in oven-sh#36920) has been timing out in the Linux parallel lane since the day it landed (debian/ubuntu/alpine; builds 89333, 90274, 90329, 90444, 90571). It was written on the assumption that with an empty `PATH` and no `EDITOR` those calls are inert; in fact each run started eight of those editor threads racing the fixture's exit. I could not reproduce the hang itself locally (0/250, idle and loaded), so I can't claim more than: the fixture now does no spawning at all, which is what the test intended, and the API reports the failure it always meant to. The test keeps its re-entrancy coverage and now asserts the four error messages instead of swallowing them. ### How did you verify your code works? Linux x64: with the current canary the updated test fails (`Received: "opened" ×4` — the silent-success bug); with this branch `bun bd test test/js/bun/util/open-in-editor-gc.test.ts` → 2 pass. (The test is Linux-only because macOS auto-detect probes `/Applications`.) <!-- 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/open-in-editor-gc.test.ts <!-- robobun:evidence:end -->
…s survive an open editor Replaces the no-editor call storm (since #37210 those calls throw before spawning anything) with two fixtures that keep a fake editor alive while the assertions run: SigCgt from /proc/self/status must not change, and a process.on("SIGUSR2") listener must still run instead of the signal being forwarded to the editor. Co-authored-by: Minh Vu <38443830+fallintoplace@users.noreply.github.com>
What does this PR do?
Bun.openInEditor(file, { editor: "does-not-exist" })— or a call with no editor when nothing can be auto-detected — was meant to throwCould not find editor "…"/Failed to auto-detect editor, but never did.EditorContext::detect_editorstoresSome(Editor::None)once it has looked everywhere (name,EDITOR/VISUAL,PATH, fallback app paths) and found nothing, so it doesn't search again; the twois_none()checks inopen_in_editortherefore never fired and the call fell through toEditor::openwith an empty binary path, which starts a detached thread thatspawn_syncsargv[0] = ""and swallows the failure. From JS it looked like success. This treatsEditor::Noneas "not found" at those checks (EditorContext::found()), making both errors reachable; nothing is spawned.Why now:
test/js/bun/util/open-in-editor-gc.test.ts› "survives re-entrant calls from option getters" (added in #36920) has been timing out in the Linux parallel lane since the day it landed (debian/ubuntu/alpine; builds 89333, 90274, 90329, 90444, 90571). It was written on the assumption that with an emptyPATHand noEDITORthose calls are inert; in fact each run started eight of those editor threads racing the fixture's exit. I could not reproduce the hang itself locally (0/250, idle and loaded), so I can't claim more than: the fixture now does no spawning at all, which is what the test intended, and the API reports the failure it always meant to.The test keeps its re-entrancy coverage and now asserts the four error messages instead of swallowing them.
How did you verify your code works?
Linux x64: with the current canary the updated test fails (
Received: "opened" ×4— the silent-success bug); with this branchbun bd test test/js/bun/util/open-in-editor-gc.test.ts→ 2 pass. (The test is Linux-only because macOS auto-detect probes/Applications.)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/open-in-editor-gc.test.ts