Skip to content

[test/DNM] ci: try to reproduce the conmon job hang - #677

Closed
kolyshkin wants to merge 18 commits into
containers:mainfrom
kolyshkin:test-conmon-hang-repro
Closed

[test/DNM] ci: try to reproduce the conmon job hang#677
kolyshkin wants to merge 18 commits into
containers:mainfrom
kolyshkin:test-conmon-hang-repro

Conversation

@kolyshkin

Copy link
Copy Markdown
Collaborator

Do not merge. This is #673 plus one throwaway commit on top, to hunt a hang.

What we are chasing

Twice today the conmon job went quiet mid-suite and stayed that way until it was killed:

03:48:35  ok 67 exec requires proper arguments
          <17 minutes of nothing>
04:05:24  The job has exceeded the maximum execution time of 20m0s

Test 68 is integration: exec exit codes work correctly in test/06-exec-exit-status.bats. It runs six podman commands, and until #673 exactly one of them -- the initial probe -- had a timeout. The prime suspect is the container creation:

container_id=$(podman --conmon $conmon_path run -dt "$UBI10_MICRO_IMAGE" sleep 30)

A command substitution waits for stdout to be closed, and conmon is started by podman with that very stdout. This test deliberately runs the freshly built conmon, so it is exactly the thing that might hold it open.

Nothing is printed while it hangs, because bats shows a test's output only once the test ends -- so from the outside the suite simply goes silent.

What this branch does

  • runs the conmon job ten times in parallel (matrix.attempt, fail-fast: false), since it does not reproduce locally at all and in CI hits maybe one run in several;
  • drops the cri-o job and switches coverage and validate to workflow_dispatch, so nothing else competes for runners;
  • keeps timeout-minutes: 20 from test: fix some conmon test flakes/races #673, so a wedged attempt dies in 20 minutes rather than 6 hours;
  • dumps the surviving conmon/podman/runc/crun processes and podman ps -a if the container creation times out.

Reading the results

Both outcomes are useful:

  • not ok 68 integration: exec exit codes work correctly in some attempts -> the suspect is confirmed, and the process dump says who was still alive;
  • an attempt wedging for the full 20 minutes anyway -> the hang is somewhere else, and the log says after which test.
  • all ten green -> it is rarer than one in ten, and the honest answer is that test: fix some conmon test flakes/races #673's timeouts contain it without explaining it.

🤖 Generated with Claude Code

kolyshkin and others added 9 commits August 18, 2026 19:14
$CONMON_PID_FILE is a single path per test, but a test may start more
than one conmon (one for the container and one for an --exec), in which
case the second one overwrites the first one's pidfile, and there is no
way to tell what the first conmon's pid was.

Have start_conmon_with_default_args use a pidfile of its own for every
conmon it starts, and expose the pid of the one just started as
$CONMON_PID, so that tests do not have to deal with the path at all.
$CONMON_PID_FILE stays for the tests that run conmon directly.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
The test waits for the container to reach the "stopped" state and then
reads the container log, expecting the container output to be there.
This is racy: the container being stopped does not mean conmon is done
writing the log, and the test can (and in CI does) read an empty log:

	not ok 92 attach: container finishes immediately without --stdin
	# (from function `bail-now' in file conmon/test/test_helper.bash, line 519,
	#  from function `assert' in file conmon/test/test_helper.bash, line 635,
	#  in test file conmon/test/07-attach.bats, line 37)
	#   `assert "${output}" =~ "Container stopped!"  "'Container stopped!' found in the log"' failed
	#|     FAIL: 'Container stopped!' found in the log
	#| expected: =~ Container stopped\!
	#|   actual:    ''

conmon having exited does mean the log is complete, so add a helper
waiting for that, and use it in the affected test.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
The same pattern as fixed by the previous commit -- wait for the
container to be stopped, then read a conmon log -- is used in a few more
places. These have not been seen to fail, but the race is the same, so
convert them, too.

Where a test starts more than one conmon, $CONMON_PID is saved right
after the start, and only the conmon(s) owning the log(s) actually read
are waited for.

The most common instance of the pattern is run_conmon_with_default_args
itself, so the barrier goes right into it, covering all of its callers.
This one has been seen to fail:

	not ok 45 ctr logs: k8s partial message
	#   `assert "${output}" =~ "stdout P"' failed
	#| expected: =~ stdout P
	#|   actual:    ''

in the coverage job, where conmon is built with --coverage and is thus
slow enough to lose the race more often.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The intent was to wait for conmon to exit, but the argument is the name
of the pidfile rather than the pid it contains, so bash rightfully
complains

	wait: `/tmp/.../conmon-pidfile': not a pid or valid job spec

which the test then discards along with the rest of the errors. In other
words, this waits for nothing at all. Use wait_for_conmon_exit instead.

The sleep above it is left as is: it is what gives the sync pipe reader
time to write out what conmon sent it.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
The GitHub Actions default job timeout is 6 hours, so a job that hangs
squats a runner for the rest of the day instead of failing. This is not
hypothetical: today both the setup step (apt, five git clones, wget) and
the test step (which starts with a podman pull) have been seen hanging on
what looks like stalled network I/O, and one such job was still sitting
there 2.5 hours later.

Give the jobs timeouts a few times their normal runtime, so a hang fails
in minutes rather than hours: the conmon job normally takes about 4
minutes and the cri-o one about 40.

Note static.yml already sets one (360, the default), left alone here.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This script clones five repositories, downloads a tarball and a repo key,
and runs apt, and not one of those has a usable timeout by default:

 - git has none at all;
 - curl has no total timeout and no retries, only a 300 second connect
   timeout, so a connection that stalls mid-transfer hangs forever;
 - wget does have a 900 second read timeout, but also 20 tries by
   default, which adds up to hours;
 - apt retries nothing by default.

This is not theoretical: this step has been seen hanging for 49 minutes,
and was still going when the job was cancelled.

Give each of them a bound. The git ones get GIT_HTTP_LOW_SPEED_LIMIT and
GIT_HTTP_LOW_SPEED_TIME, which abort a connection that transfers less
than 1 KiB/s for a minute, and cover all five clones at once.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
setup_suite pulls the image the test rootfs is made of. It is the only
network access in the whole suite, podman has no timeout of its own, and
a hang there stops the suite before a single test runs -- with no output
at all, since bats shows setup_suite's output only once it completes.

The test step has been seen hanging for 35 minutes with exactly that
signature. Bound the pull, and say in the failure message that a timeout
is a possibility.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five seconds for a container to change state is not much when the CI
runner is loaded, and it has been observed to be too little:

	not ok 51 runtime: container execution with multiple log drivers
	not ok 101 ctrl: resize the terminal, negative width and height
	#| FAIL: timed out waiting for 'stopped' from conmon-test-...

The helper polls, so a longer limit costs nothing when things are well --
it returns as soon as the state is reached -- and only comes into play
where the alternative is a spurious failure. Make it 30 seconds.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"integration: exec exit codes work correctly" runs six podman commands,
of which exactly one -- the initial probe -- has a timeout. The rest can
hang forever, and one of them did:

	ok 67 exec requires proper arguments
	<17 minutes of nothing>
	The job has exceeded the maximum execution time of 20m0s

Test 68 is this one. Nothing is printed while it hangs, because bats
shows a test's output only once the test ends, so the suite just goes
quiet -- and before the job timeout added earlier in this series, quiet
for up to six hours.

The container creation is the likeliest culprit:

	container_id=$(podman --conmon $conmon_path run -dt ... sleep 30)

a command substitution waits for stdout to be closed, and conmon is
started by podman with that very stdout -- this test runs the freshly
built conmon on purpose, so it is exactly the thing that may hold it
open.

Wrap every podman invocation here in a timeout. A hang now fails the
test in a minute, with the rest of the suite still running.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kolyshkin
kolyshkin force-pushed the test-conmon-hang-repro branch from 83b4add to 0f04698 Compare August 19, 2026 06:20
kolyshkin and others added 9 commits August 18, 2026 23:42
The Acquire::*::Timeout options added in the previous commit turned out
not to be enough. They apply to individual requests of the acquisition
method, while the frontend will happily wait for that method forever,
which is exactly what happened:

	04:56:18 + sudo apt -o Acquire::Retries=3 ... update
	04:57:27 Get:5 https://archive.ubuntu.com/ubuntu noble-security InRelease [126 kB]
	         <19 minutes of nothing>
	05:16:30 The job has exceeded the maximum execution time of 20m0s

Five of ten parallel jobs died that way in one experiment, all of them
after apt gave up on the azure.archive.ubuntu.com mirror (a screenful of
"Ign:") and fell back to archive.ubuntu.com.

Bound the whole command instead, and retry it a couple of times, since a
stalled mirror is a transient thing. Also stop waiting on the dpkg lock,
which a previous timed out run may well have left behind, and switch to
apt-get, whose CLI is the one meant for scripts.

Three minutes per attempt: the mirror has since stalled again, and three
attempts have to fit in the job timeout with the tests still to run --
at five minutes each they took a quarter of an hour to reach a verdict,
which is most of the twenty minutes the job gets.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
start_conmon_with_default_args swallowed a failing conmon:

	run timeout 10s "$CONMON_BINARY" ... "${extra_args[@]}"

	if [ "$status" -ne 0 ]; then
		return
	fi

so a test whose conmon died, or was killed by that 10 second timeout,
carried on regardless and failed thirty seconds later in
wait_for_runtime_status, reporting that the container never reached the
expected state -- while the actual error, sitting right there in $output,
was thrown away. Debugging that from a CI log is guesswork.

Die instead, with the status and the output. The behaviour was load
bearing for eight tests which use the helper to check that conmon
rejects what it should reject, so give them a helper that says as much:
run_conmon_expecting_failure, which runs conmon and leaves $status and
$output alone.

While at it, factor the conmon invocation the two share into _run_conmon,
rather than having the argument list twice.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every conmon a test starts was told to write the container's pid to the
same $PID_FILE, so the runtimes of a container and of an exec into it
were handed the same --pid-file. runc writes that file by creating
".<name>" with O_EXCL and renaming it afterwards, which makes a shared
path a race waiting to happen -- and it happens:

	<ntrace>: calling runtime args: /usr/bin/runc create --pid-file /tmp/conmon-test-oXpcPe/pidfile <ctr>
	<ntrace>: calling runtime args: /usr/bin/runc exec --pid-file /tmp/conmon-test-oXpcPe/pidfile --process ... --detach <ctr>
	<nwarn>: runtime stderr: level=error msg="exec failed: open /tmp/conmon-test-oXpcPe/.pidfile: file exists"
	<nwarn>: Failed to read pidfile: Failed to open file ".../pidfile": No such file or directory

The exec then never runs, the container waits for it forever, and the
test fails thirty seconds later saying the container did not stop -- a
symptom bearing no resemblance to the cause. This accounted for a dozen
failures across the parallel runs used to chase it, in every job that
hit it.

The window is between runc saving the container state, which is what
"runc state" starts reporting as "created", and runc writing the pid
file a moment later: the test sees "created" and starts the exec while
the create's temporary file is still there.

Give each conmon its own, exposed as $CONTAINER_PIDFILE, the way
$CONMON_PIDFILE already is. Both tests that read the pid want the last
conmon started -- the only one in 04-runtime, the exec one in 08-exec --
which is exactly what they now get, rather than relying on the exec's
runtime having clobbered the container's file.

$PID_FILE stays for the tests that run conmon directly, where there is
only ever one conmon and nothing to collide with.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trying to reproduce the hang seen twice today, where the test suite goes
quiet after "ok 67" and stays that way until the job times out. It does
not reproduce locally at all, and in CI it happens perhaps one run in
several, so: ten parallel attempts of the conmon job, fail-fast off, and
nothing else running to compete for runners (the cri-o job is dropped
here, coverage and validate are switched to workflow_dispatch).

Note the previous commit bounds every podman call in the test that is the
prime suspect, so a recurrence should now show up as

	not ok 68 integration: exec exit codes work correctly

rather than as a wedged job. Either outcome is informative: the former
confirms the suspect, the latter means the hang is somewhere else. The
test also dumps the surviving conmon/podman processes on the way out.

Not for merge.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first round of this experiment reproduced the hang in 2 of 10
attempts, and moved the suspect:

	ok 67 exec requires proper arguments
	not ok 68 integration: exec exit codes work correctly
	# (in test file test/06-exec-exit-status.bats, line 83)
	# FAIL: true command should succeed

with 62.4 and 61.7 seconds between the two lines, i.e. exactly the
"timeout 60" firing. So it is not the container creation that hangs, as
guessed, but the very first

	podman --conmon $conmon_path exec "$container_id" true

The container is created fine, and everything after the hung exec runs
normally.

Collect what a hang leaves behind, before tearing the container down:
the process list with wchan and elapsed time, each conmon's State, its
kernel stack and its open fds, podman ps, and conmon's journal. Then
retry the exec with podman debug logs, to see whether the container is
wedged for good or only that one exec was.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two attempts reproduced the hang with identical process trees:

	31832  PPID=1      S   do_wait        60s  bin/conmon --api-version 1 -c d2ebb658... -u d2ebb658...
	31836  PPID=31832  Sl  futex_do_wait  60s  /usr/local/bin/podman ... --exit-command-arg container --exit-command-arg cleanup

conmon is in wait4() on its child, which is the "podman container
cleanup" exit command podman itself asked conmon to run, and that child
is deadlocked in a futex. conmon holds nothing -- its fds are the three
standard ones, all on /dev/null -- so the bug is inside podman.

Get the Go side of it. SIGQUIT is not an option: the cleanup process
inherits conmon's fds, so its stderr is /dev/null and the goroutine dump
would go nowhere. Attach gdb instead, and take the kernel stack of every
thread while at it. Also dump /proc/locks, a cleanup deadlock being a
plausible candidate for a database or storage lock.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hang is a deadlock inside "podman container cleanup", and there are
two podman builds on the runner: the image's own static bundle in
/usr/local/bin (5.8.4, built without the systemd tag -- see
actions/runner-images#14569, which reports health checks silently not
working for the same reason) and Ubuntu's package in /usr/bin (4.9.3),
which our install_packages pulls in. /usr/local/bin comes first in PATH,
including sudo's secure_path, so the bundle is what runs today.

Add a second matrix dimension that removes the bundle, leaving Ubuntu's
podman. Twenty jobs, ten of each. If the hang only ever shows up with
the bundle, that is the answer, and the fix is to drop it in
remove_runtimes along with the bundled runc and crun.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second failure mode -- "class B" -- is a container that never
reaches the stopped state:

	not ok 94 exec: simple --exec --exec-process-spec
	  from function `wait_for_runtime_status' in test/test_helper.bash
	  `wait_for_runtime_status "$CTR_ID" stopped' failed

It hits both podman arms of the matrix, it is always an exec test (both
08-exec.bats and the exec that 10-ctrl.bats does), and thirty seconds of
patience does not help, so it is a wedge rather than slowness. In the
last run it also came in a batch of eleven, in the two jobs that also hit
the podman cleanup deadlock -- and never in the tests running before it.

We know nothing else about it, because that path just says "timed out".
Move dump_hang_state to the shared helper and call it there, with the
container id, so the dump also shows the runtime's view of the container
and its init process.

What this should settle: whether a wedged podman from test 68 is still
sitting there when this happens (making it a knock-on effect of the
cleanup deadlock), or whether it is only conmon and the runtime (making
it a bug of its own, and one reproducible without podman in the picture).

The dump is capped at three per suite: a wedged run fails a dozen tests
in a row, gdb alone is allowed a minute, and the first dumps are the
interesting ones anyway.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "class B" failures come down to this sequence, from the journal of an
exec conmon in CI:

	<ntrace>: calling runtime args: /usr/bin/runc exec --pid-file .../pidfile --process .../process.json --detach <ctr>
	<nwarn>: Failed to read pidfile: Failed to open file ".../pidfile": No such file or directory

runc exec failed, so no pidfile was written, and conmon warned and exited
1 -- from the daemonized child, long after the parent returned 0, so the
caller sees nothing wrong. The container then waits forever for what the
exec was supposed to do, and the test fails half a minute later saying
the container never stopped, with the actual error nowhere to be seen.

Why runc exec failed we do not know, and cannot know from here: for exec,
the branch that reads the runtime's stderr is skipped, since a non-zero
runtime exit there is normally the exec'd command's own exit status.

A missing pidfile, though, means the process was never started at all, so
at that point the runtime's stderr is exactly what we want. Read it (with
the pipe made non-blocking, so that nothing hangs when there is nothing
to say) and log it, along with the runtime's exit status.

This is for the investigation. The real bug -- conmon exiting silently
without reporting the failure on the sync pipe -- wants a separate fix.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kolyshkin
kolyshkin force-pushed the test-conmon-hang-repro branch from fda7abc to 90bd3df Compare August 19, 2026 07:13
@kolyshkin

Copy link
Copy Markdown
Collaborator Author

Enough statistics collected: five runs of the 10x2 matrix, ~14 hangs of the podman cleanup deadlock with the bundled podman against 0 without it, and the exec pidfile race identified and fixed in #673 (0 occurrences in the last 20 jobs).

The findings live in #673 (test fixes and CI containment) and #679 (removing the bundled podman); the deadlock itself is written up for whoever takes it upstream. The branch stays around in case the experiment needs re-running.

@kolyshkin kolyshkin closed this Aug 19, 2026
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