Skip to content

fix(network-agent): retain taps until cleanup and pool prep complete#801

Open
FakeLearne wants to merge 3 commits into
TencentCloud:masterfrom
FakeLearne:fix/tap-cleanup-retain-on-failure
Open

fix(network-agent): retain taps until cleanup and pool prep complete#801
FakeLearne wants to merge 3 commits into
TencentCloud:masterfrom
FakeLearne:fix/tap-cleanup-retain-on-failure

Conversation

@FakeLearne

Copy link
Copy Markdown
Contributor

Keep TAP devices out of the reusable pool when sandbox cleanup fails. Port mappings and CubeVS policy metadata must be cleaned successfully before a TAP can be recycled, so stale allow/deny state cannot leak into a later sandbox that reuses the same ifindex.

Move the default private/link-local deny ranges out of the sandbox creation hot path. TAPs now receive those invariant deny_out entries when they are initialized or asynchronously prepared for the pool, while replace/recover paths still replay them after flushing policy maps.

}
}
s.stageTapForPoolLocked(restoredTap, "recover")
if err := s.prepareAndStageTapForPool(context.Background(), restoredTap, "recover"); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale eBPF allow entries may survive when restoring taps not in CubeVS on restart

When inCubeVS is false, recover() skips the cleanup block and calls prepareAndStageTapForPool directly, which only calls PrepareTAPDevicePolicy — it does not flush existing eBPF map entries. If the agent crashed mid-cleanup (after some entries were flushed but before cubevsDelTAPDevice completed), stale allow entries from the previous sandbox survive in allow_out_v2. A new sandbox using this ifindex via the non-replace applyNetPolicy path will inherit them, potentially allowing traffic that its policy intended to block.

Consider calling cleanupNetPolicy(ifindex) (or flushing both maps) before re-preparing taps that lack CubeVS metadata on restart.

return
}
closeTapFile(tap.File)
_ = destroyTapFunc(tap.Index)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

destroyTapFunc error silently discarded on non-pool tap release

In a PR that otherwise tightens cleanup rigor, _ = destroyTapFunc(tap.Index) silently discards the destruction error. If the host-side tap device cannot be destroyed, the IP is still released and could be re-allocated for a new tap while the old device still exists on the host. Consider logging this error and/or returning it so the caller can decide whether to retry.

return cubevsPrepareTAPPolicy(uint32(tap.Index))
}

func (s *localService) prepareAndStageTapForPool(ctx context.Context, tap *tapDevice, reason string) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

TOCTOU window: lock gap between prepare and stage

s.mu is released between prepareTapForPool (which does eBPF syscalls) and stageTapForPoolLocked. Under concurrent sandbox teardown+creation, another goroutine calling acquireTap can find the pool temporarily empty and create a fresh tap from scratch, defeating pool efficiency and potentially wasting the preparation work done here.

Consider staging the tap into the pool first (under lock, which is fast) and then doing the eBPF preparation asynchronously via the abnormal pool — or document this intentional trade-off.

@chenhengqi chenhengqi self-assigned this Jul 8, 2026
Comment thread CubeNet/cubevs/netpolicy.go Outdated
allowOutEntries []allowOutPolicyEntry
dnsAllowRules []dnsAllowRule
denyOutEntries []denyOutPolicyEntry
includeDefaultDenyOut bool

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.

No need for this bool flag.

}
}
s.stageTapForPoolLocked(restoredTap, "recover")
if err := s.prepareAndStageTapForPool(context.Background(), restoredTap, "recover"); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Potential IP resource leak when quarantine is reached

When recover() fails to stage a tap (line 790) or handleAbnormalTaps reaches the quarantine threshold (line 361-363), the IP allocated by s.allocator.Assign at line 741 is never released. After maxAbnormalRecoveryAttempts (3), the tap moves to quarantinedTaps and the IP leaks for the process lifetime.

A persistent eBPF failure on a single tap can slowly exhaust the sandbox IP pool. Consider releasing the IP before quarantining, e.g. s.allocator.Release(tap.IP) at lines 362-363 and at line 793 before the requeue call.

s.clearPortMappings(staleTap),
s.cleanupCubeVSTap(device.Ifindex, staleTap.IP),
)
if cleanupErr != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hard failure on stale state cleanup can block node startup

If cleanup of a stale persisted state fails (transient eBPF error, etc.), recover() returns the error to NewLocalService, preventing the agent from starting at all. Every other cleanup failure in this function correctly enqueues to the abnormal pool and continues.

Consider making this best-effort: log the error, quarantine the ifindex, and continue recovery of other states. Otherwise a single corrupted eBPF entry can indefinitely wedge the node.

HostPort: int32(hostPort),
ContainerPort: mapping.ContainerPort,
})
tap.PortMappings = append([]PortMapping(nil), actualMappings...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

O(N²) copy: tap.PortMappings re-allocated on every loop iteration

This creates a new backing array and copies the entire actualMappings slice once per port mapping. For N mappings, N(N+1)/2 elements are copied (e.g., 10 ports → 55 elements). Consider moving this assignment to after the loop when all mappings have been built successfully.

}
denyOutEntries = appendDenyOutPolicyEntries(denyOutEntries, alwaysDeniedSandboxEntries)
}
if err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hard to trace err reuse across conditional branches

err is written in one branch (AllowInternetAccess=false → line 338) and checked after the conditional (line 347). If AllowInternetAccess is nil, err retains whatever value it had from line 328, which should be nil after the check at line 329 but requires tracing three possible sources to verify. Using local-scoped err variables or an explicit err = nil before the outer if would make this self-documenting.

@cubesandboxbot

cubesandboxbot Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review: PR #801 — fix(network-agent): retain taps until cleanup and pool prep complete

Overview

This PR addresses a real correctness issue: TAP devices with incomplete cleanup should not be recycled, as stale eBPF allow/deny state could leak into a different sandbox that reuses the same ifindex. The architectural change is sound — moving alwaysDeniedSandboxEntries out of the per-buildNetPolicyPlan hot path into a pool-preparation step, and implementing a carefully graduated abnormal-tap state machine (retry → quarantine) for taps that cannot be cleaned or prepared.

The code is well-structured, the concurrency tests are thorough, and the error-sentinel pattern (tapCleanupError) is well-designed. Below are the findings I consider worth addressing before merging.


Medium: IP resource leak when taps are quarantined

File: network-agent/internal/service/local_service.go:790 and tap_lifecycle.go:361-363

When a tap fails recovery and hits the quarantine threshold (maxAbnormalRecoveryAttempts), the IP allocated by s.allocator.Assign is never released. The isTapMissingError path correctly releases the IP (line 359), but the general recovery-failure quarantine path (line 361-363) does not. Similarly, recover() enqueues to the abnormal pool on prepareAndStageTapForPool failure (line 790-795) without releasing the IP allocated at line 741.

On a node with persistently failing taps (e.g., hardware issues), this slowly exhausts the sandbox IP pool. Add s.allocator.Release(tap.IP) before inserting into quarantinedTaps at lines 362-363 and at line 793.

Inline comment posted.


Medium: Recover() hard-fails on stale-state cleanup, risking startup failure

File: network-agent/internal/service/local_service.go:811-818

The stale-state cleanup loop in recover() returns a hard error if clearPortMappings or cleanupCubeVSTap fails for a single stale state. This prevents NewLocalService from completing, wedging the node. Every other cleanup failure in recover() correctly enqueues to the abnormal pool and continues.

Make this best-effort: log the error, quarantine the ifindex, and continue recovering other states. A transient eBPF failure should not prevent the agent from starting.

Inline comment posted.


Medium: O(N²) copy in configurePortMappings hot path

File: network-agent/internal/service/tap_lifecycle.go:201

tap.PortMappings is re-assigned inside the loop on every iteration, creating a new backing array and copying the entire actualMappings slice each time. For N port mappings this does N(N+1)/2 element copies.

Move tap.PortMappings = append([]PortMapping(nil), actualMappings...) to after the loop completes. The field now serves dual purpose (partial rollback tracking + final result), so the post-loop assignment is correct once all iterations succeed.

Inline comment posted.


Medium: Hard-to-trace err reuse in buildNetPolicyPlan

File: CubeNet/cubevs/netpolicy.go:347

The err variable is written in one branch (AllowInternetAccess=false) and checked after the conditional. When neither branch writes err (AllowInternetAccess is nil/true AND DenyOut is nil), err retains whatever value it had from line 328. The check at line 329 guarantees it's nil, but this requires reading three code paths to verify. Use local-scoped variables or add err = nil before the conditional.

Inline comment posted.


Low: mustBuildDenyOutPolicyEntries panics at package init time

File: CubeNet/cubevs/netpolicy.go:25, 247-253

alwaysDeniedSandboxEntries is populated at var init time via a panic-on-error helper. While the current CIDRs are compile-time constants, any future edit that introduces an invalid CIDR causes a runtime panic at program start rather than a recoverable initialization error. Consider building this lazily or in NewLocalService through the normal error path.


Low: Documentation inaccuracies

  • acquireTap comment (local_service.go:473-477): Says the mutex "is only held for the pool dequeue and the conflict membership check" — but these are two separate lock/unlock cycles (the dequeue at 479-481, then cleanupConflictingTap re-acquires via checkTapConflict around line 856). Rephrase to reflect separate acquisitions.
  • createState comment (local_service.go:395): Says "ctx is currently only used for logging" but ctx is passed to pushEgressForState (line 469), which does a non-logging HTTP call. Update to "primarily used for logging; also propagated to the egress push."
  • effectiveDenyOutEntriesForReplace (netpolicy.go:381): Missing doc comment explaining this appends always-denied entries for replace paths after flushing. Add one.
  • warmupTapPoolBackground comment (tap_lifecycle.go:282): Says "then exit" but the function returns normally. Change to "then return."

Informational: Test coverage gaps

The concurrency and lifecycle tests are thorough. Notable gaps:

  1. handleAbnormalTaps isTapMissingError path — no mock causes tryRecoverAbnormalTap to return a netlink.LinkNotFoundError. The IP release + inventory refill branch is untested.
  2. Prepare-pool failure cascade — no test validates that handleAbnormalTaps retries preparation and quarantines after maxAbnormalRecoveryAttempts failures via requeuePreparePoolFailureLocked.
  3. effectiveDenyOutEntriesForReplace nil planreplaceNetPolicy with zero options could skip installing alwaysDenied entries if the plan is nil (the function returns nil). Add a test.
  4. configurePortMappings internal markTapCleanupError — the wrapping when port allocation fails and clearPortMappings also fails (line 180-181) is never tested.

Positive highlights

  • TOCTOU is correctly closed — the creating guard channel in EnsureNetwork/ReleaseNetwork prevents the release-before-create race, with dedicated concurrency tests proving parallelism and mutual exclusion for the same sandbox.
  • Port mapping preservation on delete failureclearPortMappings correctly preserves failed-to-delete entries in tap.PortMappings and does not release the associated host port, preventing port hijacking.
  • Async pool preparation — the enqueuePreparePoolLocked / requeuePreparePoolFailureLocked / quarantinedTaps state machine is well-structured and correctly prevents reuse until policy preparation succeeds.
  • ErrKeyNotExist handling — consistently applied across flushDNSAllowInnerMap, flushInnerMapWithValue, cleanupCubeVSTap, and clearPortMappings, avoiding spurious error propagation for already-deleted entries.

HostPort: int32(hostPort),
ContainerPort: mapping.ContainerPort,
})
tap.PortMappings = append([]PortMapping(nil), actualMappings...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

O(N²) copy: tap.PortMappings re-allocated on every loop iteration

This creates a new backing array and copies the entire actualMappings slice once per port mapping. For N mappings, N(N+1)/2 elements are copied (e.g., 10 ports → 55 elements). Consider moving the assignment to after the loop when all mappings have been built successfully.

yarrischen added 2 commits July 8, 2026 19:47
Keep TAP devices out of the reusable pool when sandbox cleanup fails. Port mappings and CubeVS policy metadata must be cleaned successfully before a TAP can be recycled, so stale allow/deny state cannot leak into a later sandbox that reuses the same ifindex.

Move the default private/link-local deny ranges out of the sandbox creation hot path. TAPs now receive those invariant deny_out entries when they are initialized or asynchronously prepared for the pool, while replace/recover paths still replay them after flushing policy maps.

Signed-off-by: yarrischen <yarrischen@tencent.com>
Remove the includeDefaultDenyOut branch and treat the private/link-local
deny ranges as baseline TAP policy in all paths. Replace/flush now always
replays those default deny entries, including deny-all sandboxes, keeping
create and replace policy shapes consistent while simplifying the policy
plan logic.

Signed-off-by: yarrischen <yarrischen@tencent.com>
@fslongjin fslongjin moved this from Todo to In progress in CubeSandbox Jul 8, 2026
Make TAP recycling safer by ensuring a TAP is cleaned and prepared before
it is returned to the available pool. On the normal sandbox delete path,
prepare the TAP synchronously and only move it to the abnormal retry queue
if preparation fails.

Also clear per-sandbox net policy and DNS allow state before replaying
default deny entries during TAP pool preparation, so restarted
network-agent instances do not reuse TAPs with stale policy residue.

Restore the HostPort ownership check when rolling back failed port map
installation, so only dynamically allocated host ports are released back
to the allocator.

Signed-off-by: yarrischen <yarrischen@tencent.com>
@FakeLearne FakeLearne force-pushed the fix/tap-cleanup-retain-on-failure branch from ff4a5ed to f5d301d Compare July 9, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

3 participants