Skip to content

fix(tunnel): report an opentunnel that cannot connect as a failed command - #597

Open
Jisung Chae (jisung-02) wants to merge 10 commits into
mainfrom
596-opentunnel-sync-open
Open

Jisung Chae (jisung-02) wants to merge 10 commits into
mainfrom
596-opentunnel-sync-open

Conversation

@jisung-02

@jisung-02 Jisung Chae (jisung-02) commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #596

Summary

opentunnel started the tunnel in a goroutine and replied with exit code 0 before it dialed, so a tunnel that never connected reached Alpacon as an opened tunnel and only the host log said why. The handler now opens the tunnel before it replies: a refused dial, a code-server manager that cannot be created, or a relay that does not start returns exit code 1 with opentunnel: <reason>. Clients that read the command result, such as alpacon tunnel since alpacax/alpacon-cli#636, can then show that reason instead of waiting on a tunnel that is not there.

Changes

  • RunTunnelBackground is split into Open(ctx) and Serve(). Open runs initCodeServerManager, connect and the relay start inside handleOpenTunnel; on failure it unregisters and closes the tunnel. Serve keeps relaying streams in a goroutine and cleans up when the session ends.
  • The dial follows the command context and the tunnel's own context. gorilla does not watch the context while it reads the handshake response, so connect installs a NetDialContext that closes the dial socket on cancellation. A closetunnel during the open now fails it at once instead of waiting out the 30-second HandshakeTimeout.
  • A tunnel closed before or during the open fails it with ErrTunnelClosedWhileOpening, so a closed or abandoned tunnel is never reported as opened. open checks closed before it dials, because connect cancels the dial through context.AfterFunc, which runs in its own goroutine even on a context that is already done. A close that lands during the relay start is reported the same way, whether the relay stub returns or startTunnelRelay fails on the canceled tc.ctx. The last check also requires the tunnel to still be registered, because CloseTunnel and CloseAllActiveTunnels unregister it before Close sets closed. A command context that ends before the relay start returns fails the open as well.
  • The handler logs ErrTunnelClosedWhileOpening at Info and every other open failure at Error. An open interrupted by shutdown mostly ends with the canceled command context instead, since shutdown cancels the root context before CloseAllActiveTunnels runs, and is still logged at Error.
  • A refused handshake reports its status through the existing handshakeError, for example opentunnel: failed to connect to tunnel server: websocket: bad handshake (HTTP 403).
  • connMu and a closed flag hand wsConn, session and codeServerMgr from Open to Close without a race. Close keeps its order: stop code-server, then cancel. It no longer sets codeServerMgr to nil, which a concurrent health check could read between its nil check and its use.
  • Cleanup unregisters a tunnel only while it is still the registered entry. Before, the goroutine's cleanup deleted by session ID and could remove a newer tunnel opened under the same ID after a closetunnel.
  • CheckSystemResources is a handler field (checkResources) and the relay start a TunnelClient field (startRelay), so tests do not depend on the host's CPU load or spawn the daemon.

Things to note

  • A successful opentunnel now replies after the dial and relay start rather than before them. The dial still starts as soon as the command arrives, so the moment the agent reaches the tunnel server does not move. One pool worker is held for the open, at most about 35 seconds (30-second handshake timeout plus the 5-second daemon wait). A closetunnel queued behind busy workers therefore waits at most that long; shell commands already hold the same workers for up to 30 minutes. Keeping control commands off the shared pool, or capping concurrent opens, is tracked in Tunnel opens can hold every shared pool worker for up to 35 seconds #598.
  • Not changed here: a dedicated exit code for the resource-limit refusal (follow-up, see opentunnel reports success before the tunnel connects #596), enforcing the RegisterTunnel, Open, Serve order in the type, and moving the tunnel dial onto pkg/wsclient.
  • handleHealthCheck still reads codeServerMgr without the lock, as before; this PR only removes the write that made that read able to panic.

Testing

  • go test -race -count=1 -p 1 ./... passes on the pushed head, rebased onto main at d04c244.
  • New tests in pkg/runner/tunnel_client_test.go: a successful open, a relay that cannot start, a refused handshake with its status, a closetunnel while the handshake is held (waits on the server receiving the request, not on a sleep), the command context ending, an open after close that makes no TCP connection, a close during the relay start, a close that fails the relay start, the command context ending during the relay start, a tunnel unregistered but not yet closed, Serve unregistering when the server ends the session, and cleanup leaving a newer tunnel under the same ID. The held-handshake test failed before the NetDialContext change. The relay-start test failed before the tc.ctx check with failed to start tunnel relay: tunnel session closed while waiting for daemon socket: context canceled. The open-after-close test fails 6 of 100 runs under GOMAXPROCS=1 without the early closed check and 0 of 350 with it; the race is rare, so it cannot fail every run. The command-deadline test now requires context.DeadlineExceeded or os.ErrDeadlineExceeded, and fails when the server URL points at an unresolvable host.
  • New tests in pkg/executor/handlers/tunnel/tunnel_test.go: a refused dial returns exit code 1 with the exact result and leaves nothing registered (this test reproduced the bug first), an editor whose code-server manager cannot be created (Linux only, since macOS uses the current user), and the resource-limit refusal.
  • go vet for linux, darwin and windows passes. golangci-lint reports only the two existing unused findings in cmd/alpamon/command/register/register.go and pkg/runner/commit_types.go.

Korean: none

Copilot AI lite review requested due to automatic review settings October 26, 2026 06:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved lifecycle and cancellation issues include potential command-worker starvation and reporting success after cancellation or closure.

2 open issues
What changed in this PR

This pull request makes opentunnel report connection and relay-start failures synchronously instead of returning premature success.

Changes:

  • Split tunnel startup into synchronous Open and asynchronous Serve phases.
  • Added context-aware dialing, handshake error reporting, lifecycle synchronization, and cleanup safeguards.
  • Added tests covering failed opens, cancellation, relay failures, resource refusals, and stale tunnel cleanup.
File Description
pkg/​runner/​ws_url_test.go Updates tunnel connection tests to pass a context.
pkg/​runner/​tunnel_client.go Implements synchronous opening, cancellation, relay startup, synchronized lifecycle handling, and cleanup.
pkg/​runner/​tunnel_client_test.go Tests successful and failed opens, cancellation, relay startup, serving, and tunnel replacement behavior.
pkg/​executor/​handlers/​tunnel/​tunnel.go Opens tunnels before returning command results and injects resource checks.
pkg/​executor/​handlers/​tunnel/​tunnel_test.go Tests refused connections, editor setup failures, and resource-limit refusals.

Review effort: Lite

Comment thread pkg/executor/handlers/tunnel/tunnel.go Resolved
Comment thread pkg/runner/tunnel_client_test.go Resolved

@alpacax-wall-d alpacax-wall-d 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.

WALL-D review: Approval withheld · head 21c807d

Code review result: Comments only (1 P1/P2) · head 21c807d

Review summary

  • Found 1 P1/P2 defect.

A maintainer will review this PR.

Product review result: Nothing flagged · head 21c807d

Re-review requests and limits

WALL-D reviews a PR opened ready after this rule took effect once on its own. After that it reviews only on request: comment @alpacax-wall-d review or mark a draft ready for review, up to 5 times per PR including the first review. A re-review reads only the lines changed since the last review, and from the second re-review on only P1 findings block approval. Answering the product review's questions reviews only the product side again.

Comment thread pkg/runner/tunnel_client.go Resolved
@alpacax-wall-d alpacax-wall-d Bot added the needs-author-fix WALL-D: fix needed on the head (P1/P2 finding, product fix-before-merge item, or failing CI) label Oct 26, 2026
@jisung-02

Copy link
Copy Markdown
Contributor Author

@alpacax-wall-d review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved tunnel lifecycle issues remain around close/open ordering and relay-start cancellation error handling.

1 open issue
2 resolved since last review

Review effort: Lite

Comment thread pkg/runner/tunnel_client.go Resolved
@alpacax-wall-d alpacax-wall-d Bot removed the needs-author-fix WALL-D: fix needed on the head (P1/P2 finding, product fix-before-merge item, or failing CI) label Oct 26, 2026
@jisung-02

Copy link
Copy Markdown
Contributor Author

@alpacax-wall-d review

Copilot AI previously approved these changes Oct 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approved

The reviewed changes are covered by tests and have no unresolved approval-blocking issues.

No open issues 🎉

1 resolved since last review

Review effort: Lite

@alpacax-wall-d

Copy link
Copy Markdown

A review is already queued.
#597 (comment)

alpacax-wall-d[bot]
alpacax-wall-d Bot previously approved these changes Oct 26, 2026

@alpacax-wall-d alpacax-wall-d 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.

WALL-D review: Approved · head 30584f9 · 1 earlier finding resolved, Product review result: Nothing flagged

@alpacax-wall-d

Copy link
Copy Markdown

Reviewed 30584f9: #597 (review)
#597 (comment)

@geunwoonoh Geunwoo Noh (geunwoonoh) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

The design is solid and the tests cover the boundary cases well. I found nothing that should block the merge. The three earlier Copilot and WALL-D findings (command context cancellation, t.Error in the seam, the gap between unregistering and setting closed) are all addressed in the code and covered by tests.

Critical issues (0)

Warnings (1)

  1. pkg/executor/handlers/tunnel/tunnel.go:166 Performance: one open now holds a shared pool worker for up to about 35 seconds (30-second handshake plus 5-second daemon wait), where it used to hold it for close to zero. A burst of opens against a blackholed tunnel server can fill all 20 workers, delaying shell commands and closetunnel by up to 35 seconds; once the 200-slot queue fills, commands are refused with job queue is full. The Copilot thread already explains the upper bound and the reasoning holds, so this does not block the merge, but a follow-up issue for a semaphore on concurrent opens or a separate path for control commands is worth filing.

Suggestions (7)

  1. pkg/runner/tunnel_client.go:169, tunnel_client_test.go:426-435 An already closed tunnel can still dial. context.AfterFunc(tc.ctx, cancel) runs cancel in its own goroutine even when the context is already done, so a localhost dial can finish and send the upgrade request first. The result is still errTunnelClosedWhileOpening, so there is no production impact, but the test name (DialsNothing...) states a contract the code does not guarantee, and assert.Empty(t, hits) reads a channel the server handler fills asynchronously, so it can miss a late hit. An early check in open() makes the contract deterministic.
  2. pkg/runner/tunnel_client.go:206, :617 isRegistered duplicates GetActiveTunnel, which had no production caller on main and only test callers after this PR. Have isRegistered reuse it, or merge the two.
  3. pkg/executor/handlers/tunnel/tunnel_test.go:317 This setServerURL shares its name with the one in pkg/runner but takes *httptest.Server instead of string. The packages cannot share it, but matching the signature avoids confusion.
  4. pkg/runner/tunnel_client_test.go:419 require.Error alone passes on an unrelated error such as a refused URL. Narrow it to the two cases the comment names: errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) (check first that sanitizeURLError keeps the wrapping).
  5. pkg/executor/handlers/tunnel/tunnel.go:167 A user closing a tunnel right after opening it (errTunnelClosedWhileOpening) is logged with log.Error. Picking that case out with errors.Is and logging it at Info or Warn cuts alert noise.
  6. pkg/runner/tunnel_client.go:233 mgr.cancel() reaches into another type's unexported field. Nothing has started yet, so mgr.Stop() does the same thing (with a nil cmd it only cancels) and keeps teardown on one API.
  7. pkg/runner/tunnel_client.go:332 The ctx parameter of the NetDialContext closure shadows the ctx of connect(ctx), which is easy to confuse with the outer ctx.Err() just below. A name like dctx reads more clearly.

Further notes

Go conventions: lowercase error strings, %w wrapping, a sentinel error, the Open/open split, require versus assert, and t.Context() all follow convention. The startRelay and checkResources seams match the existing demote and terminateFn pattern. Network-backed tests staying on the real clock matches the CLAUDE.md guidance.

Duplicate or unused code

  • GetActiveTunnel/isRegistered: see suggestion 2.
  • Wrapping in handshakeError{StatusCode: resp.StatusCode, ...} appears here and at auth_backoff.go:108. Two sites do not justify a helper yet.
  • The closed flag looks redundant with tc.ctx.Err() but is needed: while Close stops code-server (the grace wait), the context is not canceled yet, and a session connect installs in that window would miss Close's snapshot and leak. Setting closed under the lock closes that window.
  • In !unwatch() || dialCtx.Err() != nil the first condition implies the second, but unwatch() must run for its side effect of removing the AfterFunc. Intended.

Performance

  • Cost: warning 1. A successful reply also waits for the dial RTT, TLS, and the daemon start (up to 5 seconds on Unix), which is the point of the fix.
  • Gains: a closetunnel during the handshake frees the socket and worker at once instead of after the 30-second timeout; a failed open no longer stays registered or deletes a newer tunnel with the same ID; the data race on codeServerMgr = nil in Close is gone.
  • Overhead: one WithCancel, two AfterFuncs and one RLock on the global map per open. Negligible.

Concurrency (no issues found)

  • Locks are taken only in the order connMu then activeTunnelsMu (read). CloseTunnel, CloseAllActiveTunnels and unregisterAndClose all release activeTunnelsMu before calling Close, so there is no reverse path and no deadlock.
  • In gorilla v1.5.3, the wss and HTTP proxy paths also go through NetDialContext, so cancellation closes the proxy TCP connection too.
  • A health check that arrives after Close and calls StartAsync cannot start code-server, because the manager's context is already canceled.

Highlights

  • The failure cause now reaches the command result, and reusing handshakeError carries a reason such as (HTTP 403) through to the client.
  • unregisterAndClose deletes only its own entry, which cleanly closes the session ID reuse race.
  • The held-handshake test waits on the server receiving the request rather than on a sleep, and the regression test was confirmed to fail before the fix.

Verdict: APPROVED, with warning 1 left to a follow-up issue.

Checklist

  • Code follows stack conventions
  • Tests included and passing (CI green)
  • No security vulnerabilities
  • Documentation updated if needed (not applicable)


go tunnelClient.RunTunnelBackground()
// Open before replying so a tunnel that cannot open fails the command.
if err := tunnelClient.Open(ctx); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Warning: performance. Open now runs on the shared pool worker for up to about 35 seconds (30-second handshake plus 5-second daemon wait), where the old go RunTunnelBackground() returned at once. A burst of opens against a blackholed tunnel server can fill all 20 workers and delay shell commands and closetunnel by up to 35 seconds; once the 200-slot queue fills, commands are refused with job queue is full. The bound explained in the Copilot thread holds, so this does not block the merge, but a follow-up issue for a semaphore on concurrent opens or a separate path for control commands is worth filing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed on the cost, and not changed in this PR: a semaphore taken inside the pool job would still hold the worker while it waits, so the fix needs either control commands off the shared pool or a cap checked before submitting. Filed as #598 with both directions.

return nil
}

func (tc *TunnelClient) open(ctx context.Context) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggestion: an already closed tunnel can still dial. context.AfterFunc(tc.ctx, cancel) in connect runs cancel in its own goroutine even when tc.ctx is already done, so a localhost dial can finish and send the upgrade request first. The result is still errTunnelClosedWhileOpening, so there is no production impact, but TestTunnelOpen_DialsNothingAfterTheTunnelWasClosed states a contract the code does not guarantee. An early check makes it deterministic:

Suggested change
func (tc *TunnelClient) open(ctx context.Context) error {
func (tc *TunnelClient) open(ctx context.Context) error {
if tc.ctx.Err() != nil {
return errTunnelClosedWhileOpening
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5cbd26f. You were right that context.AfterFunc runs cancel in its own goroutine even on a context that is already done, so a closed tunnel could start the dial. open now reads closed under connMu before doing anything; Close sets it before it cancels tc.ctx, so it also catches a close that is still stopping code-server.

Comment thread pkg/runner/tunnel_client_test.go Outdated
err := tc.Open(t.Context())

require.ErrorIs(t, err, errTunnelClosedWhileOpening)
assert.Empty(t, hits)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggestion: assert.Empty(t, hits) reads a channel the server handler fills asynchronously, so it can pass even when a request arrives slightly later. With the early tc.ctx.Err() check in open() suggested above, the dial cannot start at all and this assertion becomes reliable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5cbd26f, differently from the suggestion alone. The early check made the dial impossible, but the old assertion still could not prove it: with the check removed it passed 300 of 300 runs. The test now listens on a raw TCP socket and accepts after Open returns, since a loopback connect is queued before the dial returns. Without the check it fails 6 of 100 runs under GOMAXPROCS=1, with it 0 of 350; the race is rare, so no run can force it.

Comment on lines +206 to +210
func (tc *TunnelClient) isRegistered() bool {
activeTunnelsMu.RLock()
defer activeTunnelsMu.RUnlock()
return activeTunnels[tc.sessionID] == tc
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggestion: this duplicates GetActiveTunnel, which had no production caller on main and has only test callers after this PR. Reusing it keeps one lookup:

Suggested change
func (tc *TunnelClient) isRegistered() bool {
activeTunnelsMu.RLock()
defer activeTunnelsMu.RUnlock()
return activeTunnels[tc.sessionID] == tc
}
func (tc *TunnelClient) isRegistered() bool {
got, _ := GetActiveTunnel(tc.sessionID)
return got == tc
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested in 241aa5c: isRegistered now calls GetActiveTunnel instead of repeating its lookup.


// setServerURL makes srv the configured Alpacon server, which is the only
// host a tunnel URL may name.
func setServerURL(t *testing.T, srv *httptest.Server) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggestion: pkg/runner has a test helper with the same name, setServerURL(t, serverURL string). The packages cannot share it, but matching that signature (taking srv.URL) avoids two helpers with one name and different arguments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in 241aa5c: the handler tests' setServerURL now takes the URL string, matching the runner helper of the same name.

Comment thread pkg/runner/tunnel_client_test.go Outdated

err := tc.Open(ctx)

require.Error(t, err) // gorilla's own deadline may report it first as an i/o timeout

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggestion: require.Error alone also passes on an unrelated error, such as a refused URL. Narrowing it to the two cases the comment names, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded), makes the test prove the deadline stopped the dial (check first that sanitizeURLError keeps the wrapping).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in e03b258. sanitizeURLError wraps with %w, so both cases match through errors.Is. With the server URL pointed at an unresolvable host, the old assertion passed on lookup console.invalid: no such host and the new one fails.

Comment thread pkg/executor/handlers/tunnel/tunnel.go Outdated
go tunnelClient.RunTunnelBackground()
// Open before replying so a tunnel that cannot open fails the command.
if err := tunnelClient.Open(ctx); err != nil {
log.Error().Err(err).Str("sessionID", args.SessionID).Msg("Tunnel open failed.")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggestion: a user closing a tunnel right after opening it returns errTunnelClosedWhileOpening and is logged here at Error level. Picking that case out with errors.Is and logging it at Info or Warn keeps it out of error alerts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in 9054216: the sentinel is exported as ErrTunnelClosedWhileOpening and the handler logs it at Info. Checking that path turned up one more case, fixed in bcb52e2: a close during the daemon wait made the real startTunnelRelay return the canceled tc.ctx error, which open wrapped as a relay failure, so it still reached Error. open now returns the sentinel when tc.ctx is done after the relay start fails, with a test that failed before. An open cut short by shutdown mostly ends on the canceled command context instead, since shutdown cancels the root context before CloseAllActiveTunnels, so it stays at Error; b19a560 makes the sentinel's doc say so.

Comment thread pkg/runner/tunnel_client.go Outdated
tc.connMu.Lock()
defer tc.connMu.Unlock()
if tc.closed {
mgr.cancel()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggestion: mgr.cancel() reaches into an unexported field of CodeServerManager. Nothing has started yet, so mgr.Stop() does the same thing (with a nil cmd it only cancels) and keeps teardown on one API.

Suggested change
mgr.cancel()
_ = mgr.Stop()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested in 241aa5c. With a nil cmd, Stop() only cancels, so the behavior is the same.

Comment thread pkg/runner/tunnel_client.go Outdated
Comment on lines +332 to +333
dialer.NetDialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
c, err := (&net.Dialer{}).DialContext(ctx, network, addr)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggestion: this ctx parameter shadows the ctx of connect(ctx), and the outer one is read a few lines below in fmt.Errorf("tunnel dial stopped: %w", ctx.Err()). A distinct name reads more clearly.

Suggested change
dialer.NetDialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
c, err := (&net.Dialer{}).DialContext(ctx, network, addr)
dialer.NetDialContext = func(dctx context.Context, network, addr string) (net.Conn, error) {
c, err := (&net.Dialer{}).DialContext(dctx, network, addr)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested in 241aa5c.

@jisung-02

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Every item was checked against the code and held. The branch is rebased onto main at d04c244, which brought in #587; the open path still fits its macOS demotion (NewCodeServerManager and an unstarted manager's Stop() behave as this PR assumes).

Item Commit
Warning: one open holds a pool worker for up to 35 seconds Not changed here, tracked in #598
1. A closed tunnel can still dial 5cbd26f
1. assert.Empty(t, hits) can miss a late hit 5cbd26f (raw TCP accept instead)
2. isRegistered duplicates GetActiveTunnel 241aa5c
3. setServerURL signature mismatch 241aa5c
4. require.Error passes on unrelated errors e03b258
5. ErrTunnelClosedWhileOpening logged at Error 9054216, bcb52e2, b19a560
6. mgr.cancel() reaches into an unexported field 241aa5c
7. ctx shadowed in NetDialContext 241aa5c

Two more commits came out of a full review of the branch: 33ea8be drops errRelayMustNotStart, which no test asserted once the deadline check changed, and bcb52e2 is the relay-start case described in the reply on item 5.

Verification on b19a560:

  • go build ./... passes.
  • go vet ./... passes for linux, darwin and windows.
  • go test -race -count=1 -p 1 ./... passes.
  • golangci-lint run ./... reports only the two existing unused findings in cmd/alpamon/command/register/register.go and pkg/runner/commit_types.go.

…mand

opentunnel started the tunnel in a goroutine and replied with exit code 0
before the dial, so a tunnel server refusal, a code-server manager that
could not be created or a relay that did not start reached Alpacon as an
opened tunnel and only the host log said why. The handler now runs Open
first: it prepares code-server, dials the tunnel server and starts the
relay, and on failure unregisters the tunnel and returns exit code 1 with
the reason. Serve keeps relaying streams in the background.

The dial follows the command context and the tunnel's own. gorilla does
not watch the context while it reads the handshake response, so the dial
socket is closed on cancellation, and a closetunnel during the open now
fails it instead of waiting out the handshake timeout. A refused handshake
reports its HTTP status. Cleanup unregisters a tunnel only while it is
still the registered entry, so it no longer removes a newer tunnel under
the same session ID.

Fixes #596
- fail Open when the command context ends during the relay start
- return an error from the test relay seam instead of calling t.Error
- fail Open when CloseTunnel has unregistered the tunnel but not yet closed it
connect cancels its dial through context.AfterFunc(tc.ctx, cancel), and
AfterFunc runs cancel in its own goroutine even when tc.ctx is already
done. Open on a tunnel that was already closed could therefore connect
and send the upgrade request before the cancel landed, although it still
returned errTunnelClosedWhileOpening.

open now reads tc.closed under connMu before doing anything. Close sets
closed before it cancels tc.ctx, so the check also catches a close that
is still stopping code-server.

The test counted hits on an HTTP handler, which records them
asynchronously and could miss a late request. It now listens on a raw
TCP socket and accepts after Open returns: a loopback connect is queued
before the dial returns, so any dial Open made is already there. Without
the check, it fails 6 of 100 runs under GOMAXPROCS=1; with it, 0 of 350.
A user who closes a tunnel right after opening it makes Open return
the closed-while-opening error, and the handler logged it with
log.Error like any other open failure. That is a close the user asked
for, not a fault, so it no longer belongs in error alerts.

Export the sentinel as ErrTunnelClosedWhileOpening so the handler can
pick it out with errors.Is and log it at Info. The command result is
unchanged: it still fails with the same message.
The test for a command context ending mid-handshake only required some
error, so a dial that failed for an unrelated reason passed it too. With
the server URL pointed at an unresolvable host, the old assertion passed
on "lookup console.invalid: no such host".

Require the error to be context.DeadlineExceeded or, when gorilla's own
conn deadline reports first, os.ErrDeadlineExceeded. sanitizeURLError
wraps with %w, so both still match through errors.Is. The same mutation
now fails the test.
- isRegistered repeated GetActiveTunnel's lookup under the same lock;
  it now calls GetActiveTunnel.
- initCodeServerManager called mgr.cancel(), an unexported field of
  CodeServerManager. Nothing has started yet, so Stop() only cancels
  and keeps teardown on one API.
- The NetDialContext closure's ctx shadowed connect's ctx, which is read
  a few lines below in the "tunnel dial stopped" error; it is now dctx.
- The handler tests' setServerURL took *httptest.Server while the
  runner tests' helper of the same name takes a string; it now takes
  the URL string too.
relayMustNotStart returned a package-level errRelayMustNotStart so one test could assert it was not the error. That assertion became the deadline check, leaving the sentinel unread; the stub now builds its error inline.
startTunnelRelay checks tc.ctx before spawning the daemon and while it
waits for the daemon socket, and returns tc.ctx's error when the tunnel
closes. open wrapped that as "failed to start tunnel relay", so a
closetunnel or shutdown during the up-to-5-second daemon wait reached
the console as a relay failure and the handler logged it at Error
instead of recognizing ErrTunnelClosedWhileOpening.

open now returns ErrTunnelClosedWhileOpening when tc.ctx is done after
the relay start fails, as connect already does for its dial. The new
test closes the tunnel inside the relay stub and returns the error the
way startTunnelRelay does; it failed before this change with
"failed to start tunnel relay: tunnel session closed while waiting for
daemon socket: context canceled".
The sentinel's doc said it reports a close "by closetunnel or
shutdown", but shutdown cancels the root context before
CloseAllActiveTunnels runs, so an open interrupted by shutdown mostly
ends with the canceled command context instead. The doc now names the
mechanism, Close, rather than the callers. The handler's Info branch
said "a close the user asked for", which left out the shutdown case
that does reach it when CloseAllActiveTunnels wins the race.
@alpacax-wall-d

Copy link
Copy Markdown

If this PR is ready, ask for a review by commenting @alpacax-wall-d review. If it is still in progress, convert it to a draft.

WALL-D: no review was requested in the 24 hours since the last push to d8a4d57. Rule: WALL-D reviews a PR only when asked · To stop it: request a review or convert to draft. Once per commit.

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.

opentunnel reports success before the tunnel connects

3 participants