Summary
ControlModeSessionTests.A_canceled_attach_is_disposed_before_the_call_returns fails intermittently on the tmux compatibility matrix with TmuxTransportException: The tmux client process could not be started, wrapping System.ComponentModel.Win32Exception: ... Text file busy. The test writes a shell wrapper, marks it executable, and points ServerConnectionOptions.tmuxBinaryPath at it, so what fails is execve returning ETXTBSY. TmuxCapabilitiesTests.CreateExecutable builds a fake tmux the same way and is exposed to the same race.
Filed as an observation rather than a reproduction: it has been seen once in CI and did not reproduce locally in 25 attempts under deliberate fork pressure.
Why it happens
File.WriteAllTextAsync closes its own handle before returning, so an unclosed writer is not the cause. On Linux execve fails with ETXTBSY while any process holds the target inode open for writing. Tests run in parallel and this suite starts tmux processes constantly, so a Process.Start on another thread can fork inside the write window and inherit the wrapper's write descriptor. O_CLOEXEC does not close that descriptor at fork, only at exec, so the forked child keeps the inode open for writing until it execs its own image.
Expected
The wrapper the test just wrote is executable, and Server.ConnectAsync starts it.
Actual
execve returns ETXTBSY, surfaced as TmuxTransportException, and the test fails.
Thirteen of the fourteen matrix legs passed on the same commit. tmux 3.7a on net8.0 passed while tmux 3.7a on net10.0 failed, and re-running the failed leg on that same commit passed.
Reproduction
Not reproduced on demand. Running the class in a loop while a second suite generated process-start pressure did not fire it:
$ TMUX_TMPDIR=/tmp/libtmux-dotnet-test dotnet test \
--project tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj \
--configuration Release \
--framework net10.0 \
--filter-class '*ControlModeSessionTests*'
Twenty-five iterations, with six concurrent runs of ChainGenerationTests as load: no failures. The window between another thread's fork and its exec is short enough that a local loop does not reliably land in it.
Environment
Where it was seen, and where it was not
|
Seen in CI |
Local attempt |
| Result |
failed |
0 of 25 |
| tmux |
3.7a, built from source |
3.5a |
| Framework |
net10.0 |
net10.0 |
| OS |
ubuntu-latest runner |
Linux 6.18 (WSL2) |
| SDK |
pinned by global.json |
10.0.302, runtime 10.0.11 |
Evidence
Failure from the tmux 3.7a on net10.0 leg
failed LibTmux.IntegrationTests.ControlMode.ControlModeSessionTests.A_canceled_attach_is_disposed_before_the_call_returns (50ms)
Xunit.MicrosoftTestingPlatform.XunitException: LibTmux.TmuxTransportException : The tmux client process could not be started.
---- System.ComponentModel.Win32Exception : An error occurred trying to start process '.../libtmux-control-start-<guid>/tmux-wrapper' ... Text file busy
at LibTmux.Internal.TmuxProcessTransport.ExecuteAsync(TmuxCommandRequest request, CancellationToken cancellationToken) in src/LibTmux/Transport/TmuxProcessTransport.cs:123
at LibTmux.Internal.TmuxConnection.DetectImplementationAsync(CancellationToken cancellationToken) in src/LibTmux/Connection/TmuxConnection.cs:373
at LibTmux.Server.ConnectAsync(CancellationToken cancellationToken) in src/LibTmux/Server.Identity.cs:66
----- Inner Stack Trace -----
at System.Diagnostics.Process.ForkAndExecProcess(...)
at System.Diagnostics.Process.Start(ProcessStartInfo startInfo)
Test run summary: Failed!
total: 892
failed: 1
succeeded: 891
Proposal
Retry the start on ETXTBSY in the test helpers, bounded and with a short delay, then confirm the flake is gone by repeating the affected leg rather than by a single green run.
Renaming the file into place does not fix this, and is worth recording so nobody spends an afternoon on it: ETXTBSY is a property of the inode, not of the path, so a forked child holding the write descriptor still blocks execve after a rename. Copying has the same problem, because the copy is itself opened for writing.
Invoking the script through an interpreter — passing it as an argument to /bin/sh rather than executing it directly — avoids the race entirely, because a file opened for reading is not subject to ETXTBSY. That would need ServerConnectionOptions to carry arguments alongside tmuxBinaryPath, which it does not today.
References
Summary
ControlModeSessionTests.A_canceled_attach_is_disposed_before_the_call_returnsfails intermittently on the tmux compatibility matrix withTmuxTransportException: The tmux client process could not be started, wrappingSystem.ComponentModel.Win32Exception: ... Text file busy. The test writes a shell wrapper, marks it executable, and pointsServerConnectionOptions.tmuxBinaryPathat it, so what fails isexecvereturningETXTBSY.TmuxCapabilitiesTests.CreateExecutablebuilds a fake tmux the same way and is exposed to the same race.Filed as an observation rather than a reproduction: it has been seen once in CI and did not reproduce locally in 25 attempts under deliberate fork pressure.
Why it happens
File.WriteAllTextAsynccloses its own handle before returning, so an unclosed writer is not the cause. On Linuxexecvefails withETXTBSYwhile any process holds the target inode open for writing. Tests run in parallel and this suite starts tmux processes constantly, so aProcess.Starton another thread canforkinside the write window and inherit the wrapper's write descriptor.O_CLOEXECdoes not close that descriptor atfork, only atexec, so the forked child keeps the inode open for writing until it execs its own image.Expected
The wrapper the test just wrote is executable, and
Server.ConnectAsyncstarts it.Actual
execvereturnsETXTBSY, surfaced asTmuxTransportException, and the test fails.Thirteen of the fourteen matrix legs passed on the same commit.
tmux 3.7a on net8.0passed whiletmux 3.7a on net10.0failed, and re-running the failed leg on that same commit passed.Reproduction
Not reproduced on demand. Running the class in a loop while a second suite generated process-start pressure did not fire it:
Twenty-five iterations, with six concurrent runs of
ChainGenerationTestsas load: no failures. The window between another thread'sforkand itsexecis short enough that a local loop does not reliably land in it.Environment
Where it was seen, and where it was not
global.jsonEvidence
Failure from the tmux 3.7a on net10.0 leg
Proposal
Retry the start on
ETXTBSYin the test helpers, bounded and with a short delay, then confirm the flake is gone by repeating the affected leg rather than by a single green run.Renaming the file into place does not fix this, and is worth recording so nobody spends an afternoon on it:
ETXTBSYis a property of the inode, not of the path, so a forked child holding the write descriptor still blocksexecveafter a rename. Copying has the same problem, because the copy is itself opened for writing.Invoking the script through an interpreter — passing it as an argument to
/bin/shrather than executing it directly — avoids the race entirely, because a file opened for reading is not subject toETXTBSY. That would needServerConnectionOptionsto carry arguments alongsidetmuxBinaryPath, which it does not today.References
ControlModeSessionTests.cs#L170-L181TmuxCapabilitiesTests.cs#L634-L645execve(2)specifiesETXTBSYas "the executable was open for writing by one or more processes": man7.org/linux/man-pages/man2/execve.2.htmlCONTRIBUTING.md