Skip to content

Don't call System.exit() when the tunnel is embedded - #29

Merged
jochen-testingbot merged 1 commit into
masterfrom
fix/no-system-exit-when-embedded
Jul 29, 2026
Merged

Don't call System.exit() when the tunnel is embedded#29
jochen-testingbot merged 1 commit into
masterfrom
fix/no-system-exit-when-embedded

Conversation

@jochen-testingbot

Copy link
Copy Markdown
Contributor

The problem

App is used as a library as well as a command line client. The TeamCity agent plugin constructs an App and calls boot() inside the build agent JVM.

When the tunnel fails to start, boot() called System.exit(1). Embedded, that terminates the host process. Observed on a TeamCity 2024.12 agent with deliberately invalid credentials:

[16:49:10] : Starting TestingBot Tunnel
[16:49:10] : Processing shutdown hook: JVM process got terminate signal
[16:49:10] : Stopping build on agent. Reason: agent shutdown (Build agent shutdown)

The running build then hung in "running" indefinitely, and the agent never re-registered — it needed a manual restart. The calling plugin already wraps boot() in a try/catch, but that cannot help, because System.exit() does not throw.

This is long-standing rather than new: boot() has had a System.exit since at least 2.4.

The fix

Replace the exits on the embedding path with a TunnelFailedException carrying the exit code, and let main() catch it and exit with exactly the same codes as before, so command line behaviour is unchanged.

  • boot() throws instead of exiting when tunnel creation fails or the API returns an error (including 401)
  • startProxies() throws instead of exiting when the local proxy port cannot be opened. tunnelReady() rethrows TunnelFailedException (while still swallowing/logging everything else as before) so the failure reaches main() on the synchronous path
  • doctor() throws instead of exiting
  • trackPid() moves from boot() into main(). The pid file exists so an external supervisor can stop the process — a command line concern — and PidPoller calls System.exit(0) from a timer thread when the file disappears. That was a background thread able to kill a host JVM at any moment, e.g. if a CI workspace cleaner removed the file. Embedders no longer get it.
  • stop() now removes the shutdown hook registered by init(), cancels the pid poller, and tolerates a null api. Starting a tunnel per job previously leaked one shutdown hook per App for the lifetime of the JVM, and stop() after a failed boot() logged a confusing NPE stack trace.

After this, the only remaining System.exit calls are in main(), plus PidPoller, which is now only reachable from main().

Compatibility

  • TunnelFailedException extends RuntimeException, so no existing signature changes and nothing that compiles today stops compiling. This also matches the existing HttpProxyStartException idiom in this codebase.
  • CLI exit codes are unchanged: 1 for tunnel/auth/proxy/doctor failures, 2 for argument parse errors.
  • One deliberate behaviour change worth review: embedders that relied on boot() creating the pid file will no longer get one. That seemed clearly desirable, but say the word if you'd rather keep it and gate it on a flag instead.

Tests

mvn test: 192 passing, 0 failures (184 before, 8 added).

AppEmbeddedTest drives boot() against a WireMock-stubbed API and asserts the failure surfaces as an exception. These tests are somewhat self-enforcing: if boot() ever goes back to calling System.exit, they don't just fail, they crash the surefire JVM.

To make that testable I added a package-private App.createApi() seam so a test subclass can point the API at WireMock — boot() previously constructed new Api(this) inline with no way to intercept it. Happy to change the approach if you'd prefer a different seam.

Known limitation

On the asynchronous path (TunnelPoller timer thread calling tunnelReady()), there is no caller to propagate to, so a proxy setup failure is now logged with its real message instead of terminating the process. Previously it called System.exit(1) from that timer thread. Surfacing it properly would need a failure callback from the poller back to the caller, which felt like a design decision for you rather than something to slip into this PR.

App is used as a library as well as a command line client. The TeamCity
agent plugin, for example, constructs an App and calls boot() inside the
build agent JVM. When the tunnel failed to start, boot() called
System.exit(1), which terminated the whole build agent: the running build
hung indefinitely and the agent never re-registered until it was
restarted manually. A try/catch around boot() cannot defend against this,
because System.exit() does not throw.

Replace the exits on the embedding path with a TunnelFailedException that
carries the exit code, and let main() catch it and exit with exactly the
same codes as before, so command line behaviour is unchanged.

- boot() throws instead of exiting when tunnel creation fails or the API
  returns an error (including 401)
- startProxies() throws instead of exiting when the local proxy cannot be
  started; tunnelReady() rethrows it so the failure still reaches main()
- doctor() throws instead of exiting
- trackPid() moves from boot() to main(). The pid file exists so an
  external supervisor can stop the process, which is a command line
  concern, and PidPoller calls System.exit(0) from a timer thread when the
  file disappears. Embedders no longer get that background thread.
- stop() removes the shutdown hook registered by init(), cancels the pid
  poller and tolerates a null api, so starting a tunnel per job no longer
  leaks a hook per App instance

TunnelFailedException extends RuntimeException so no existing signature
changes and nothing that compiles today stops compiling.
@jochen-testingbot
jochen-testingbot merged commit 610046a into master Jul 29, 2026
6 checks passed
jochen-testingbot added a commit to testingbot/testingbot-teamcity-plugin that referenced this pull request Jul 29, 2026
com.testingbot.tunnel.App is a command line client that calls System.exit()
on failure and installs its own shutdown hooks. The agent plugin ran it
inside the build agent JVM, so a tunnel that could not start took the whole
agent down with it. Reproduced on TeamCity 2024.12 with invalid
credentials: the agent logged "JVM process got terminate signal" 224ms
after "Starting TestingBot Tunnel", the build hung in running state
indefinitely and the agent never re-registered until it was restarted.

The existing try/catch around boot() could not help, because System.exit()
does not throw. This is long standing rather than new; TestingBotTunnel 2.4
has the same exit inside boot(). A fix for the library side is proposed in
testingbot/Testingbot-Tunnel#29, but running the tunnel out of process
protects the agent regardless of which tunnel version is installed.

TunnelProcess launches the tunnel with the plugin's own lib directory as
the classpath, waits for the --readyfile it touches when it is usable, and
streams its output into the build log. Credentials are passed through the
environment rather than the command line so the secret does not appear in
the process list. Stopping asks politely first so the tunnel can tear down
its server side, then kills it after 30 seconds. The tunnel jar drops to
runtimeOnly, since nothing compiles against it any more.

TunnelProcessTest launches the real tunnel jar with unusable credentials.
If this ever regresses to running in process, the tests do not fail, they
kill the Gradle test JVM.
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