Skip to content

test: return from termopen only after the job's child ran exec - #593

Merged
ThomasK33 merged 4 commits into
mainfrom
fix/flake-pty-child-hangup
Nov 13, 2026
Merged

ThomasK33 merged 4 commits into
mainfrom
fix/flake-pty-child-hangup

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Why

tests/integration/diff_window_sizes_spec.lua died once under host load with Nvim: Caught deadly signal 'SIGHUP' (seen on a #573 head, green on the rerun, and main has the same exposure). The runner then reported "no plenary summary (nvim exit 1)". A test must not kill the Neovim that runs it, and a retry hides the cause.

Cause

The cause is a race inside Neovim and libuv. It is not a signal mask or disposition that stays wrong after exec.

  1. pty_process_spawn calls forkpty(). In the child, login_tty() gives it a new session on the pty.
  2. init_child() then runs setsid(), resets SIGCHLD, SIGHUP, SIGINT, SIGQUIT, SIGTERM and SIGALRM to their defaults, changes directory, builds the environment and calls execvp. Until exec the child is a copy of Neovim and has Neovim's libuv signal handlers.
  3. When the pty master closes in that gap (jobstop, a buffer wipe), the kernel sends SIGHUP to the child. libuv's inherited handler writes a message into the signal pipe that the child shares with Neovim. Neovim reads it as its own SIGHUP and exits.

strace -f on a held child shows it. The child gets SIGHUP {si_code=SI_KERNEL} and runs the handler, which writes to fd 11 on Neovim 0.8.0 (fd 8 on 0.12.3) and then one byte to a second descriptor. The parent then prints Caught deadly signal 'SIGHUP'. The 0.8.0 and 0.12.3 sources have the same order in init_child().

The spec reaches it because several tests start a terminal job (terminal.open() or termopen({ "sleep", "60" })), end within milliseconds, and after_each wipes the buffer. A child that the host scheduler delays inside the gap is enough. #336 had already measured the same race on a plain termopen followed by jobstop.

Fix

tests/helpers/termopen_wait.lua wraps vim.fn.termopen, and tests/minimal_init.lua installs it. The wrapper returns only after /proc/<pid>/exe or /proc/<pid>/cmdline of the child differs from Neovim's own, so the child has run exec and holds none of Neovim's handlers. It compares the command line too, because a job that runs Neovim itself keeps the same executable. The wait ends with an assertion after 10 s. Every integration spec and every Neovim that loads minimal_init.lua gets it. The wrapper reads /proc, so it works on Linux only (CI runs on Linux). On another system it does nothing.

  • tests/integration/pty_child_exec_spec.lua has 10 cases. Five drive the wrapper with stand-ins for /proc (a child that runs Neovim's code for N reads, a child that has run exec, a job that runs Neovim with another command line, a child that never runs exec, a job with no pid). One checks that vim.fn.termopen is the wrapper. Four use real jobs: a plain job, a job that a TermOpen autocmd stops, a job whose buffer a TermOpen autocmd wipes, and a job that runs Neovim.
  • .agents/skills/verify-claudecode-nvim/scripts/proof-pty-child-hangup.sh reproduces the race with an LD_PRELOAD shim that holds the child inside the signal(SIGCHLD, SIG_DFL) call that follows setsid(), and it checks the wrapper against it. It saves the PID and start time of each case's child and kills only that process. SKILL.md and features/terminal.md point to it.
  • DEVELOPMENT.md has a paragraph about the wrapper and what it does not cover.

No plugin code changes. The merge of origin/main brings in main's own diff.lua change.

Evidence

All runs use the official v0.8.0 release binary (NVIM v0.8.0-1210-gd367ed9b2, as in CI) or Neovim 0.12.3, with a scratch HOME and XDG dirs, on a shared host with a load average of 20 to 100. The injected rates depend on the host load.

Run main this branch
diff_window_sizes_spec with the child held 100 ms in the gap, 0.8.0 13 of 20 die by SIGHUP 0 of 20
same, 0.12.3 18 of 20 die by SIGHUP 0 of 20
diff_window_sizes_spec, no shim 0 of 200 fail (0.8.0) 0 of 20 fail on 0.8.0 and on 0.12.3
plain termopen, spin 50 to 1200 µs, jobstop, 40 runs per delay (0.8.0) 2 of 200 die by SIGHUP not applicable, it uses no minimal_init.lua
proof-pty-child-hangup.sh the wrapper case fails on 0.8.0 and 0.12.3 3 of 3 cases pass on both

The wrapper spec against 12 mutants of the wrapper (wrapper not installed, no wait, never waits because /proc/self/exe is unreadable, wait while the executable differs, wait while the command line differs, executable check only, a single 1 ms poll, no deadline assertion, a 1 ms deadline, no pcall around jobpid, no sleep, polling Neovim's own pid) and a control: the control is red 0 of 3 and every mutant is red 3 of 3, on 0.8.0 and on 0.12.3. The first version of the spec caught a single 1 ms poll only 2 of 6 times on 0.8.0 and 4 of 6 on 0.12.3.

Two cases that the first version got wrong now hold (the nested-Neovim case was wrong in the wrapper, the slow-parent case in the spec):

  • A job that runs Neovim itself. The old wait took the whole job (2 s for sleep 2) or failed after 10 s (for sleep 15). Now termopen returns after 7 to 14 ms.
  • A TermOpen autocmd that takes 50 ms. With main's minimal_init.lua plus that autocmd, the old spec was red 0 of 5 and the new spec is red 5 of 5 on both builds. With this branch's minimal_init.lua plus the same autocmd, the new spec is red 0 of 5. The child's gap after the fork is only 2 to 6 ms, even with an environment of 20000 to 200000 entries, so no setting of the spec can keep the child in Neovim's code through such an autocmd. That is why the wait is pinned with stand-ins.

The proof script left one sleep 60 per run before this fix (3 of 3 runs), and leaves none now.

Not proven or not covered

  • Natural runs of diff_window_sizes_spec on main did not die in 200 tries on this host. The original death is explained by the mechanism and by the injected runs, but I did not reproduce that exact occurrence naturally.
  • The wrapper covers vim.fn.termopen only. A job that a spec starts with jobstart and term = true is not covered. No spec does that today.
  • A stop that a TermOpen autocmd issues inside termopen runs before the wait, so it is outside the wrapper. For a job that runs Neovim with the same command line as the test Neovim, termopen blocks until that job exits and raises if it lives 10 s. A poll that lands inside exec can return early. No spec starts such a job.
  • The plugin has the same exposure outside the tests. :ClaudeCodeClose of the native terminal waits for the exec of a young job (fix(terminal): keep a native start single when an autocmd interrupts it #544). A direct jobstop or :bwipeout of the terminal buffer right after termopen, and the Snacks close, do not wait. This PR changes no plugin code.
  • Neovim and libuv have the root fix to make (block SIGHUP across forkpty() or reset the handlers before the session becomes the pty's). I did not report it upstream.

Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh

Neovim forks a pty job's child with its own libuv signal handlers and resets
them in init_child(), after login_tty() made the child the session leader.
A hangup in that gap (a jobstop, a buffer wipe or the quit that ends a file)
runs the inherited handler in the child. It writes into the signal pipe that
the child shares with Neovim, so Neovim reads a SIGHUP of its own and exits
with "Caught deadly signal 'SIGHUP'". diff_window_sizes_spec died that way
once under host load.

tests/minimal_init.lua now wraps vim.fn.termopen so that it returns after the
child's /proc/<pid>/exe no longer names Neovim. A new integration spec checks
that, and proof-pty-child-hangup.sh reproduces the race with an LD_PRELOAD
shim that holds the child in the gap.

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
@ThomasK33
ThomasK33 marked this pull request as ready for review November 12, 2026 23:48
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-13T01:56:41.390515Z 8acdbcb Manual request
🔒 Security Review ✅ Completed 2026-11-13T01:57:39.433242Z 8acdbcb Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 4c02fa0e2f

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c02fa0e2f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/minimal_init.lua Resolved
…in the spec

Review of the first head found two gaps. A job that runs Neovim itself keeps
the same /proc/<pid>/exe after exec, so the wrapper waited the full 10 s and
failed. It now also compares /proc/<pid>/cmdline with Neovim's own.

The spec could pass without the wrapper when the child won the race to exec
before the spec looked. It now gives each job an environment of 20000 entries.
The child copies it before exec, which keeps the child in Neovim's code for
milliseconds. A new case covers a job that runs Neovim itself.

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
…pt clean

Review of 4c02fa0 found that no spec step could tell a missing wrapper from
a present one when a TermOpen autocmd takes a few milliseconds: the child
runs exec first either way, and a child's gap after the fork is 2 to 6 ms even
with a large environment. So the wait now lives in tests/helpers/termopen_wait.lua
and the spec drives it with stand-ins for /proc (a child that runs Neovim's
code for N reads, a child that is Neovim with another command line, a child
that never runs exec, a job with no pid). The spec also checks that
vim.fn.termopen is the wrapper, and keeps four checks with real jobs: a plain
job, a TermOpen jobstop, a TermOpen bwipeout and a job that runs Neovim.

The proof script saves the PID and start time of the job's child and kills
only that process, so the window case leaves no `sleep 60`. Its comments say
that setsid() is the first call of init_child(). DEVELOPMENT.md says what the
wrapper does not cover, and its paragraph no longer sits between the Claude
stub paragraph and the strace recipe.

_Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh_
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 8acdbcbc75

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 8acdbcbc75

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 13, 2026
Merged via the queue into main with commit d05b010 Nov 13, 2026
8 checks passed
@ThomasK33
ThomasK33 deleted the fix/flake-pty-child-hangup branch November 13, 2026 02:52
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 14, 2026
…es (coder#597)

## Why

The verifier of coder#593 passed it and left four minor findings. This PR
closes them. All four sit in the test wrapper that makes `termopen`
return after the job's child ran exec
(`tests/helpers/termopen_wait.lua`), its spec, and the notes about them.

## What changed

1. **Notes (N1).** A job with the test Neovim's exact command line keeps
its executable and command line across exec, so `termopen` blocks until
that job exits, and raises when the job lives 10 s. The verifier saw a 2
s job block for 2032 ms, and a 15 s job raise 13 of 16 times. A poll can
also land inside exec, where the command line reads empty, and return
before exec has ended (3 of 16). The helper comment and `DEVELOPMENT.md`
say so.
2. **Pointers (D1).** `SKILL.md` and the proof script named
`tests/minimal_init.lua` as the wrapper's home. It lives in
`tests/helpers/termopen_wait.lua`, and `minimal_init.lua` installs it.
3. **Defaults (K2b).** With a 50 ms `TermOpen` autocmd, a wrapper that
read nothing from `/proc` (the verifier's m09) or whose default `jobpid`
raised (m10) passed the spec 5 of 5, because the child ran exec before
`termopen` returned. Two new cases do not depend on timing. One builds
the wrapper with stand-ins for the clock and the pid only, and gives it
Neovim's own pid, so the wait must end at the deadline of the fake
clock. That reads `/proc` and the default `readlink` for real. The other
replaces `vim.fn.jobpid`, `vim.loop.hrtime` and `vim.loop.sleep`, and
builds the wrapper with no stand-ins, so the wrapper must take its
defaults from them.
4. **Clock (M11).** The same second case pins the default `hrtime`
binding, which gives the 10 s deadline of the installed wrapper. The
verifier's m11 (a clock that always reads 0) passed the spec in 3 of 3
runs before (red 0 of 3).

No file under `lua/` or `plugin/` changes.

## Evidence

Builds: the official `v0.8.0` release binary (`NVIM
v0.8.0-1210-gd367ed9b2`, as in CI) and Neovim 0.12.3. Each mutant of
`termopen_wait.lua` ran 3 times per build against the spec. "Slow" means
a 50 ms `TermOpen` autocmd added to `tests/minimal_init.lua`.

| Mutant | main's spec, plain | main's spec, slow | this spec, plain |
this spec, slow |
|---|---|---|---|---|
| control (no change) | 0 of 3 red | 0 of 3 red | 0 of 3 red | 0 of 3
red |
| m09: `read_proc` returns nil | 3 of 3 red | 0 of 3 red | 3 of 3 red |
3 of 3 red |
| m10: default `jobpid` raises | 3 of 3 red | 0 of 3 red | 3 of 3 red |
3 of 3 red |
| m11: default `hrtime` always 0 | 0 of 3 red | 0 of 3 red | 3 of 3 red
| 3 of 3 red |
| default `sleep` does nothing | not run | not run | 3 of 3 red | 3 of 3
red |
| default `readlink` returns nil | not run | not run | 3 of 3 red | 3 of
3 red |
| default `read` returns nil | not run | not run | 3 of 3 red | 3 of 3
red |

The table has the same counts on 0.8.0 and 0.12.3. The 12 mutants of
coder#593 (wrapper not installed, no wait, exe test inverted, a single 1 ms
poll, no deadline assertion, a 1 ms deadline, no `pcall` around
`jobpid`, and the others) are still red 3 of 3 on both builds against
this spec.

- Gate: `MISE_LOCKFILE=false mise run all` with GNU timeout first on
PATH exits 0 with 1904 successes, 0 failures, 0 errors and 36 pending,
and luacheck reports 0 warnings in 200 files.
- `mise run format-check`: 444 files, 0 changed.
- Integration runner: 852 successes, 0 failures, 0 errors on 0.8.0, and
856, 0, 0 on 0.12.3.
- `pty_child_exec_spec.lua`, 20 runs per build: 0 failures on 0.8.0 and
0.12.3.
- `check.sh --hash-only` is fresh for the four verify skills.

## Not covered

The case that reads `/proc` uses Neovim's own pid, so it needs a Linux
`/proc` like the rest of the spec (the spec is pending without it). The
default `sleep` is pinned only through the case that replaces
`vim.loop.sleep`. Nothing here changes how the wrapper behaves.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
hareki pushed a commit to hareki/claudecode.nvim that referenced this pull request Nov 14, 2026
…the owned stop (coder#602)

## Why

This is a follow-up to coder#593. It fixes the Snacks close and the stop in
`owned.lua`, the two close paths that coder#593 left open.

A job that was just started can still be a fork of Neovim until its
child has run `exec`. A `jobstop` on it then ends Neovim with `Caught
deadly signal 'SIGHUP'`, or the buffer delete after it hangs. coder#593
documents the race, and coder#544 made the native close wait for the exec of
a young job. The Snacks close and the stop in `owned.lua` still called
`jobstop` at once, so a close within milliseconds of the start could
kill Neovim.

## Cause

`pty_process_spawn` forks the child, and `init_child()` runs `setsid()`
and resets the signal handlers only after that. Until `exec` the child
has Neovim's libuv handlers. When the pty master closes in that gap (a
`jobstop`), the kernel sends SIGHUP to the child, libuv's handler in the
child writes into the signal pipe that it shares with Neovim, and Neovim
reads a SIGHUP of its own and exits. With the child held in the gap (see
Evidence), a close right after the start killed Neovim in every run for
the Snacks provider and for the stop in `owned.lua`, on 0.8.0 and
0.12.3. The native provider survived in every run.

## Fix

The wait moves out of `native.lua` into `owned.wait_for_exec(jobid)`,
with `owned.is_young(started_at)` for the 10 s rule. The code is the
same: it reads the names of Neovim and of the child from
`/proc/<pid>/comm` (or `nvim_get_proc` where `/proc` is missing) and
waits up to 1 s until the child has another name.

- `native.close` calls it for a job younger than 10 s, as before.
- `snacks.M.open` records `term._cc.job_started_at` where it already
patches the instance. `snacks.close` reads it and calls the wait for a
job younger than 10 s. A terminal that a reload adopted has no start
time and is not waited for, as in native. Without the age rule, every
Snacks close on macOS would pay about 200 ms for the two `ps` calls of
the `nvim_get_proc` fallback.
- `owned.lua` `stop_buffer` has no start time, so it calls it for every
job. That path runs only after a reload or a provider switch, and the
wait ends at the first look for a job that has run `exec`.

The wait runs scheduled callbacks while the job still runs. Two Codex
findings on `ea0fbfd6` were about that, and `12f1d45f` fixes both:

- `snacks.close` now clears the owned mark before the wait for every
terminal, not only for an adopted one. An `open()` among the callbacks
can no longer adopt the terminal that is closing by its mark.
- `stop_buffer` reads the channel before the wait and checks
`nvim_buf_is_valid(buf)` before `jobstop`. A callback that deleted the
buffer during the wait no longer makes `jobstop` raise `Invalid buffer
id`.

`lua/claudecode/terminal/README.md` and the `verify-terminal-lifecycle`
skill describe the three places. The skill changed as follows:

- The fact `OwnedStopOtherStops` reads `M.wait_for_exec(channel)` and
the buffer check before `jobstop`.
- The new fact `SnacksCloseClearsMark` reads the mark clear before the
wait. The model has a switch of the same name in `CloseBegin`. The model
counts a closing terminal as ended, so the fact breaks a rule only
together with `OwnedLiveSkipsEndedJob`. `check.sh --mutants` has a row
for the pair.
- `OwnedLiveSkipsEndedJob` alone now breaks `OpenShowsLiveClaude`, not
`CloseNeverReuses`, because the model clears the mark in every Snacks
close.
- The model still passes with 279492 distinct states.

## CI on `ea0fbfd6` and the fixes

Two checks failed on `ea0fbfd6`.

1. The integration spec compiled its shim into a TMPDIR folder. CI's
second pass runs with `TMPDIR="$RUNNER_TEMP/odd tmp 50%"`. `LD_PRELOAD`
splits its value at a space or a colon, so the shim was ignored, the
child was never held, and the probe timed out. `f910015b` puts the shim
into `mktemp -d /tmp/claudecode.XXXXXX` when the TMPDIR path holds a
space or a colon.
2. `verify-diff-lifecycle` also hashes
`terminal/{native,owned,snacks}.lua`, so its `covered.lock` was stale.
`3a1961a0` re-records the locks of `verify-terminal-lifecycle` and
`verify-diff-lifecycle`.

## Tests

- `tests/integration/terminal_close_young_job_spec.lua` runs one Neovim
per case with `-u NONE` (so `minimal_init.lua` does not wait for the
exec and hide the race) and `tests/helpers/hold_pty_child.c` preloaded.
The shim holds the child inside `init_child()` and creates a file when
it does. The probe (`tests/helpers/close_young_job_probe.lua`) starts a
Claude terminal job, waits for that file, and closes the job. Cases: a
plain `jobstop` as the control (it must end Neovim with SIGHUP, so the
other cases can fail), the native close, the Snacks close (with a
stand-in for Snacks) and the `owned.lua` stop. The spec is pending
without Linux, a C compiler and `timeout`. It puts the shim into a
`/tmp` folder when TMPDIR holds a space or a colon, and
`tests/helpers/ld_preload_path.lua` makes that choice.
- `tests/unit/ld_preload_path_spec.lua` has three cases: a plain path, a
path with a space and a path with a colon. Before it, a spec pattern
without the colon half passed in every CI configuration.
- `tests/unit/terminal/owned_wait_for_exec_spec.lua` covers the wait
(names from `/proc`, from `nvim_get_proc`, the 1 s limit, a job with no
pid, unreadable names), `is_young`, the order of the stop in `owned.lua`
(wait, then `jobstop`, then delete) and "leaves a buffer alone that a
callback deleted during the wait".
- `tests/unit/terminal/snacks_close_spec.lua` covers the Snacks close:
it waits before `jobstop` for a job that just started, not for a job
older than 10 s, and not for a job that exited. The case "does not let
an open during the wait adopt the terminal that is closing" covers the
mark clear.
- Five Snacks unit specs gain `vim.loop.hrtime` in their vim mock, and
one gains `vim.fn.getpid`, because the provider now reads a clock and
the wait reads the pid.
- `proof-pty-child-hangup.sh` compiles the same shim instead of a copy
of it. Under a TMPDIR with a percent sign it failed all three cases, on
`main` too. With only a space or only a colon it failed one of three,
and its wrapper case passed only because the shim was not loaded. Now
`d6019d13` puts its work folder into `/tmp` for such a path.
- The header of `hold_pty_child.c` now says that the shim holds every
fork of Neovim that calls `signal(SIGCHLD, SIG_DFL)`, including the `ps`
children that `nvim_get_proc` starts, not only the pty child.

## Evidence

Builds: the official `v0.8.0` release binary (`NVIM
v0.8.0-1210-gd367ed9b2`, as in CI) and Neovim 0.12.3, scratch HOME and
XDG dirs, on a shared host.

| Run | main plus the new tests | this branch |
|---|---|---|
| integration spec, 10 runs per build | 10 of 10 fail on both builds
(the Snacks and `owned.lua` cases fail, the control and native pass) | 0
of 10 fail on both builds |
| integration spec, 20 runs per build, plain TMPDIR | not run | 0 of 20
fail on 0.8.0 and on 0.12.3 |
| integration spec, 20 runs per build, `TMPDIR="odd tmp 50%"` | the spec
of `ea0fbfd6` times out in this TMPDIR, as in CI | 0 of 20 fail on 0.8.0
and on 0.12.3 |
| the unit specs of `tests/unit/terminal` plus
`native_terminal_toggle_spec`, 20 runs | not run | 0 of 20 fail (374
successes each) |

Mutants of the Lua files, one run each, on `89e74e31`. The merge of main
changed no terminal file and no terminal spec, so I did not rerun them
on the merge commit. "Unit" counts the failing tests among 374 in
`tests/unit/terminal` plus `native_terminal_toggle_spec`. "Integration"
is the new spec on 0.8.0 and 0.12.3, with the same result on both.
"Fact" is the `verify-terminal-lifecycle` fact that turns FALSE in a
facts-only run of `check.sh`.

| Mutant | Unit | Integration | Fact |
|---|---|---|---|
| Snacks close does not wait | 2 failures | red |
`SnacksCloseClearsMark` |
| Snacks close always waits (no age rule) | 1 failure | green (the wait
is harmless but slow) | none |
| Snacks close never waits (age rule always false) | 2 failures | red |
none |
| Snacks does not record the start time | 2 failures | red | none |
| Snacks clears the mark only for an adopted terminal (the form on
`ea0fbfd6`) | 1 error (the open adopts the closing terminal) | green |
`SnacksCloseClearsMark` |
| Snacks clears the mark after the wait | 1 error (same case) | green |
`SnacksCloseClearsMark` |
| `stop_buffer` does not wait | 2 failures | red | `OwnedStopOtherStops`
|
| `stop_buffer` has no `nvim_buf_is_valid(buf)` check before `jobstop` |
1 failure | green | `OwnedStopOtherStops` |
| `stop_buffer` re-reads the channel with no check (the form on
`ea0fbfd6`) | 1 failure | green | `OwnedStopOtherStops` |
| native close does not wait | 3 failures | red | none |
| native close never counts a job as young | 3 failures | red | none |
| `wait_for_exec` does not call `vim.wait` | 6 failures | red | none |
| `wait_for_exec` stops while the names are equal | 4 failures | red |
none |
| `wait_for_exec` waits 0 ms | 4 failures | red | none |
| `wait_for_exec` has no guard for an unreadable name of Neovim | 4
failures | green | none |
| `wait_for_exec` calls `jobpid` without `pcall` (no guard for a job
without a pid) | 1 failure and 2 errors | green | none |
| `is_young` uses `<=` | 2 failures | green | none |
| `is_young` reads a `nil` start time as young (no `nil` guard) | 1
failure | green | none |

The control mutant (no change) is green in every column, with 374
successes. I ran the facts-only `check.sh` on every row, and "none"
means that every fact stayed TRUE. A mutant of
`tests/helpers/ld_preload_path.lua` that drops the colon, and one that
drops the space, each fail one case of `ld_preload_path_spec.lua`.

The model has its own mutants. `check.sh --mutants` removes each guard
of the model in turn, and it ends with "every guard is caught" on the
merge commit `191a3e24`. The pair `SnacksCloseClearsMark` with
`OwnedLiveSkipsEndedJob` is caught by `CloseNeverReuses` and
`OpenShowsLiveClaude`.

- Gate: `MISE_LOCKFILE=false mise run clean && mise run all` with GNU
timeout first on PATH exits 0 with 1925 successes, 0 failures, 0 errors
and 38 pending, and luacheck reports 0 warnings in 206 files. The extra
pending case is the new integration spec of coder#536, which needs a real
Neovim.
- `mise run format-check`: 464 files, 0 changed.
- Integration runner, with a plain TMPDIR and with `TMPDIR="odd tmp
50%"`: 865 successes, 0 failures, 0 errors on 0.8.0, and 869, 0, 0 on
0.12.3.
- `proof-pty-child-hangup.sh` passes 3 of 3 cases on 0.8.0 and on
0.12.3, with a plain TMPDIR, with `TMPDIR="odd tmp 50%"` and (on 0.12.3)
with a TMPDIR that holds a colon. On `d6019d13`'s parent, with the odd
TMPDIR, it failed 3 of 3 on both builds.
- The five `check.sh` scripts (`verify-diff-lifecycle`,
`verify-mention-queue`, `verify-selection-tracking`,
`verify-terminal-lifecycle` and `verify-ws-connection`) all print PASS.
The branch merges `origin/main` (coder#597, coder#594, coder#474, coder#596 and coder#536). coder#536
changed `diff.lua`, its README, `verify-diff-lifecycle` (SKILL.md,
`check.sh` and `covered.lock`) and added
`diff_answer_every_call_spec.lua`. Git merged the two edits of
`verify-diff-lifecycle/covered.lock` with no conflict, and `check.sh` of
all five skills prints PASS on the merge result, so no lock needed a new
record after `3a1961a0`.
- On the merge commit `191a3e24`, the specs this PR adds or changes pass
20 of 20 runs in a plain TMPDIR and in `TMPDIR="odd tmp 50%"` (24
successes for `owned_wait_for_exec_spec`, `snacks_close_spec` and
`ld_preload_path_spec`, and 374 for the terminal unit specs plus
`native_terminal_toggle_spec`). The integration spec of this PR passes
20 of 20 runs per build in both TMPDIRs. `diff_answer_every_call_spec`
from coder#536 passes 10 of 10 runs per build in both TMPDIRs (9 successes
each run).

## Not covered

- The Snacks case uses a stand-in for Snacks, because CI installs no
`snacks.nvim`. The provider code under test is the real one, and the
stand-in starts a real pty job with `termopen`.
- The wait reads `/proc` or `ps`. Where neither gives a name, the close
goes on to `jobstop` and the race stays open, as in native before this
PR. A `ps` that hangs would hang the close for as long as it hangs. I
did not reproduce that.
- I tested the `ps` fallback only on Linux, with `/proc` hidden from
Lua. I did not run it on macOS or BSD.
- A `jobstop` that other code issues, for example `:bwipeout` of the
terminal buffer by the user right after the start, is not covered.
- Another PR that edits `native.lua`, `snacks.lua`, `owned.lua` or a
`covered.lock` must be merged one at a time with this one, and re-record
the lock after its merge.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-sonnet-5-5` • Thinking: `xhigh`_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant