Skip to content

ControlModeSessionTests fails intermittently with Text file busy #15

Description

@tony

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    flakyPasses or fails without a code change

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions