Skip to content

Commit cfd845c

Browse files
iSerganovcursoragent
authored andcommitted
poll: do not cancel ephemeral GC until Connect succeeds
With node.ephemeral.inactivity_timeout set, ephemeral nodes are usually deleted after they go offline, but under reconnect churn some departed nodes stayed in the node list as disconnected indefinitely until removed manually or until Headscale restarted. Ephemeral cleanup is timer-based via EphemeralGarbageCollector, not a periodic LastSeen scan. serveLongPoll cancelled any pending GC timer at the very start of a long-poll attempt and only rescheduled on a clean disconnect after Connect. If a reconnect cancelled the timer and then failed before Connect (for example an UpdateNodeFromMapRequest error), the deferred cleanup saw connectGen == 0 and returned without Schedule. The node remained offline with no deletion timer and no reconciler to recover it. Cancel the ephemeral GC timer only after a successful Connect, so a failed reconnect leaves an already-armed inactivity timer intact. Successful reconnects still cancel GC once the node is online, and a later disconnect reschedules as before. Add TestFailedReconnectDoesNotCancelEphemeralGC to lock in the ordering, plus IsScheduled and DeleteNodeFromStoreForTest helpers for the test. Fixes #3382 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dc3c0bc commit cfd845c

4 files changed

Lines changed: 86 additions & 8 deletions

File tree

hscontrol/db/node.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,16 @@ func (e *EphemeralGarbageCollector) Cancel(nodeID types.NodeID) {
498498
}
499499
}
500500

501+
// IsScheduled reports whether a deletion timer is currently armed for nodeID.
502+
func (e *EphemeralGarbageCollector) IsScheduled(nodeID types.NodeID) bool {
503+
e.mu.Lock()
504+
defer e.mu.Unlock()
505+
506+
_, ok := e.toBeDeleted[nodeID]
507+
508+
return ok
509+
}
510+
501511
// Start starts the garbage collector.
502512
func (e *EphemeralGarbageCollector) Start() {
503513
for {

hscontrol/poll.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,6 @@ func (m *mapSession) stopFromBatcher() {
9696
}
9797
}
9898

99-
func (m *mapSession) beforeServeLongPoll() {
100-
if m.node.IsEphemeral() {
101-
m.h.ephemeralGC.Cancel(m.node.ID)
102-
}
103-
}
104-
10599
// afterServeLongPoll is called when a long-polling session ends and the node
106100
// is disconnected.
107101
func (m *mapSession) afterServeLongPoll() {
@@ -144,8 +138,6 @@ func (m *mapSession) serve() {
144138
//
145139
//nolint:gocyclo
146140
func (m *mapSession) serveLongPoll() {
147-
m.beforeServeLongPoll()
148-
149141
m.log.Trace().Caller().Msg("long poll session started")
150142

151143
// connectGen is set by [state.State.Connect] below and captured by the deferred cleanup closure.
@@ -248,6 +240,13 @@ func (m *mapSession) serveLongPoll() {
248240

249241
connectChanges, connectGen = m.h.state.Connect(m.node.ID)
250242

243+
// Cancel ephemeral GC only after Connect succeeds. Cancelling at the start
244+
// of serveLongPoll left departed nodes without a deletion timer when a
245+
// reconnect attempt failed before Connect (issue #3382).
246+
if m.node.IsEphemeral() {
247+
m.h.ephemeralGC.Cancel(m.node.ID)
248+
}
249+
251250
m.log.Info().Caller().Str(zf.Chan, fmt.Sprintf("%p", m.ch)).Msg("node has connected")
252251

253252
// TODO(kradalby): Redo the comments here

hscontrol/poll_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/stretchr/testify/assert"
1616
"github.com/stretchr/testify/require"
1717
"tailscale.com/tailcfg"
18+
"tailscale.com/types/key"
1819
)
1920

2021
type delayedSuccessResponseWriter struct {
@@ -216,6 +217,67 @@ func TestServeLongPollWritesErrorWhenInitialMapFails(t *testing.T) {
216217
"serveLongPoll must write an HTTP error response when the initial map cannot be built, not an empty 200")
217218
}
218219

220+
// TestFailedReconnectDoesNotCancelEphemeralGC proves that a
221+
// long-poll reconnect attempt which fails before [state.State.Connect] must
222+
// not cancel a previously armed ephemeral GC timer. Cancelling at the start of
223+
// [mapSession.serveLongPoll] left departed ephemeral nodes stuck offline with
224+
// no deletion scheduled (https://github.com/juanfont/headscale/issues/3382).
225+
func TestFailedReconnectDoesNotCancelEphemeralGC(t *testing.T) {
226+
t.Parallel()
227+
228+
app := createTestApp(t)
229+
app.StartEphemeralGCForTest(t)
230+
231+
user := app.state.CreateUserForTest("eph-gc-cancel-user")
232+
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, true, nil, nil)
233+
require.NoError(t, err)
234+
235+
machineKey := key.NewMachine()
236+
nodeKey := key.NewNode()
237+
238+
_, err = app.handleRegister(context.Background(), tailcfg.RegisterRequest{
239+
Auth: &tailcfg.RegisterResponseAuth{
240+
AuthKey: pak.Key,
241+
},
242+
NodeKey: nodeKey.Public(),
243+
Hostinfo: &tailcfg.Hostinfo{
244+
Hostname: "eph-gc-cancel-node",
245+
},
246+
Expiry: time.Now().Add(24 * time.Hour),
247+
}, machineKey.Public())
248+
require.NoError(t, err)
249+
250+
nodeView, ok := app.state.GetNodeByNodeKey(nodeKey.Public())
251+
require.True(t, ok)
252+
require.True(t, nodeView.IsEphemeral(), "node must be ephemeral so Cancel would arm on long-poll")
253+
254+
node := nodeView.AsStruct()
255+
256+
// Arm a long-lived deletion timer — the state after a normal disconnect
257+
// has called afterServeLongPoll. A long expiry avoids racing the
258+
// fail-before-Connect path below.
259+
app.ephemeralGC.Schedule(node.ID, time.Hour)
260+
require.True(t, app.ephemeralGC.IsScheduled(node.ID), "test sanity: GC timer must be armed")
261+
262+
// Drop the node from the NodeStore so UpdateNodeFromMapRequest fails before
263+
// Connect, while the session still carries an ephemeral AuthKey (so the
264+
// old Cancel-on-entry path would clear the timer).
265+
app.state.DeleteNodeFromStoreForTest(node.ID)
266+
267+
writer := &recordingResponseWriter{}
268+
session := app.newMapSession(context.Background(), tailcfg.MapRequest{
269+
Stream: true,
270+
Version: tailcfg.CapabilityVersion(100),
271+
}, writer, node)
272+
273+
session.serveLongPoll()
274+
275+
assert.GreaterOrEqual(t, writer.statusCode(), http.StatusInternalServerError,
276+
"failed reconnect must write an HTTP error before Connect")
277+
assert.True(t, app.ephemeralGC.IsScheduled(node.ID),
278+
"failed reconnect must not cancel the ephemeral GC timer (issue #3382)")
279+
}
280+
219281
// TestGitHubIssue3129_TransientlyBlockedWriteDoesNotLeaveLiveStaleSession
220282
// tests the scenario reported in
221283
// https://github.com/juanfont/headscale/issues/3129.

hscontrol/state/state.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1458,6 +1458,13 @@ func (s *State) PutNodeInStoreForTest(node types.Node) types.NodeView {
14581458
return s.nodeStore.PutNode(node)
14591459
}
14601460

1461+
// DeleteNodeFromStoreForTest removes a node from the in-memory [NodeStore]
1462+
// without touching the database. Used to force [State.UpdateNodeFromMapRequest]
1463+
// failures in poll-session tests while keeping the DB row intact for later restore.
1464+
func (s *State) DeleteNodeFromStoreForTest(id types.NodeID) {
1465+
s.nodeStore.DeleteNode(id)
1466+
}
1467+
14611468
// CreateRegisteredNodeForTest creates a test node with allocated IPs. This is a convenience wrapper around the database layer.
14621469
func (s *State) CreateRegisteredNodeForTest(user *types.User, hostname ...string) *types.Node {
14631470
return s.db.CreateRegisteredNodeForTest(user, hostname...)

0 commit comments

Comments
 (0)