Skip to content

feat: make the worker handoff write path cancellable - #1237

Open
charleswool wants to merge 1 commit into
eraser-dev:mainfrom
charleswool:feat/windows-handoff-cancellation
Open

feat: make the worker handoff write path cancellable#1237
charleswool wants to merge 1 commit into
eraser-dev:mainfrom
charleswool:feat/windows-handoff-cancellation

Conversation

@charleswool

@charleswool charleswool commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1229 / #1231. Closes the two threads @ashnamehrotra and Copilot left open on #1231.

1. The write path can now be cancelled

ReadImagesPipe already took a context; the write side didn't, so a worker whose peer never arrived waited forever with no way out. WriteImagesPipe and WriteCompletionPipe now take one, and the collector and remover derive theirs from SIGTERM — a terminating pod actually unblocks the worker instead of waiting for the kill.

I changed my mind about how to do this on Linux, and it's worth explaining. On #1231 I proposed O_NONBLOCK + polling. Having written it, I don't think that's the right trade:

  • it replaces the open() syscall every existing deployment depends on for rendezvous
  • a non-blocking descriptor then has to handle EAGAIN on any payload larger than the pipe buffer, which a large image list will exceed
  • and I can't execute the Unix path locally, so the riskiest version is the one I'd be least able to check

So the blocking open is untouched. It runs on a goroutine that hands the file back if the caller is still waiting and closes it if not:

select {
case <-ctx.Done():
	return nil, ctx.Err()
case o := <-ch:
	return o.file, o.err
}

Linux keeps the exact syscall and the exact rendezvous it has always had. Only the waiting became interruptible. On Windows, dialForever becomes dial(ctx, …) and uses DialContext.

One subtlety worth flagging. WriteCompletionPipe now stats the path before opening. Left to the select, a peer that was never published and an already-done context would race, and Go would pick a winner at random — making "scanner disabled" indistinguishable from "we're shutting down". That's the signal pkg/remover uses to decide whether a scanner exists, so it can't be left to chance. This also makes the two implementations symmetric, since Windows already had to stat first.

WriteScanErasePipe keeps its signature and waits indefinitely, so out-of-tree scanners are unaffected.

2. listen no longer deletes things that aren't ours

It removed whatever sat at the endpoint path before binding. A socket left by an unclean exit does have to go — otherwise a crashed worker poisons the endpoint for every retry — but anything else there isn't ours to delete: the worker runs as NT AUTHORITY\SYSTEM and shares the volume with a scanner image we don't control.

Lstat reports ModeSocket on Windows, so the two cases are separable. Worth noting Go unlinks the socket on Close, so this only matters after an unclean exit; a clean shutdown leaves nothing behind at all.

Testing

Three new tests, all in the untagged file where they run against both implementations unless noted:

Test Covers
TestWriteImagesPipeHonoursACanceledContext the write gives up when nobody ever reads
TestListenRefusesToReplaceANonSocket a regular file is left alone, and the call fails
TestListenReclaimsAStaleSocket a crashed worker's socket is still reclaimable (Windows)

Verified locally: GOOS=linux and GOOS=windows build + vet clean, golangci-lint clean on both, full go test ./pkg/... green natively on Windows.

Standalone E2E test results

Upstream has no Windows CI, so this was validated on a personal fork and against a real AKS Windows Server 2022 node, same harness as #1231.

Harness — where the tooling lives
Cross-container handoff harness hack/ipcspike
Build + unit workflow .github/workflows/windows-ci.yaml
Manual E2E runner hack/windows-e2e.ps1

Unit tests exercise the handoff inside one process, which is not the question that matters for a rendezvous change. ipcspike runs this PR's actual pkg/utils API from two containers of one pod over an emptyDir: the producer publishes its completion endpoint, hands over an image list and waits; the consumer reads the list, checks that an unpublished endpoint is still reported as IsNotExist, then signals back.

Environment
Cluster AKS 1.35.6
Node aksnpwin000004, Windows Server 2022 Datacenter, build 10.0.20348.5386
Runtime containerd 1.7.20+azure
Pod HostProcess, base mcr.microsoft.com/windows/nanoserver:ltsc2022
Identity runAsUserName: NT AUTHORITY\SYSTEM
Shared volume emptyDir mounted into both containers
Live results — commit ee5188fc, the head of this PR

Cross-container handoff, two containers of one pod over an emptyDir:

=== consumer ===
consumer   dir            : C:\eraser-shared
consumer   ReadImagesPipe : OK 2 images in 18.234s
consumer     sha256:aaa [mcr.microsoft.com/windows/servercore:ltsc2022]
consumer     sha256:bbb [mcr.microsoft.com/windows/nanoserver:ltsc2022]
consumer   absent peer    : OK reported as IsNotExist
consumer   WriteCompletion: OK
RESULT consumer: PASS

=== producer ===
producer   dir            : C:\eraser-shared
producer   WriteImagesPipe: OK 2 images in 1ms
producer   Await          : OK "complete" after 2ms
RESULT producer: PASS

The 18s on the consumer side is the deliberate stagger in the producer container's command; it is the listener waiting, not latency.

The package's own tests, cross-compiled for windows/amd64 and run on the same node:

Microsoft Windows [Version 10.0.20348.5386]

--- PASS: TestImagesHandoffRoundTrip (1.01s)
--- PASS: TestCompletionHandoffRoundTrip (0.00s)
--- PASS: TestWriteCompletionPipeAbsentPeerIsNotExist (0.00s)
--- PASS: TestWriteImagesPipeHonoursACanceledContext (0.00s)
--- PASS: TestCompletionPipeCloseIsIdempotentlySafe (0.00s)
--- PASS: TestGetAddressAndDialer (0.00s)
--- PASS: TestSocketPathLimitBoundary (0.00s)
--- PASS: TestListenRefusesToReplaceANonSocket (0.00s)
--- PASS: TestListenReclaimsAStaleSocket (0.00s)
--- PASS: TestMkfifoUnsupported (0.00s)
--- PASS: TestNpipeDialerConnects (0.01s)
--- PASS: TestParseEndpointWithFallBackProtocol (0.00s)
--- PASS: TestParseEndpoint (0.00s)
PASS

Fork CI on the same commit, all six jobs:

success | unit tests on windows
success | build ./pkg/utils/...   (windows/amd64)
success | build ./pkg/cri/...     (windows/amd64)
success | build ./pkg/remover/... (windows/amd64)
success | linux unaffected
success | remaining windows blockers
Two observations from the run

The Linux half is verified by CI, not by me. I develop on Windows, so handoff_unix.go compiles and vets locally but never executes here — and the goroutine-based open is precisely the half I cannot run. The linux unaffected job runs go build ./... plus go test ./pkg/... ./api/... ./controllers/... on Ubuntu, so the new cancellation test did execute against the FIFO implementation:

ok  github.com/eraser-dev/eraser/pkg/utils    1.020s
ok  github.com/eraser-dev/eraser/pkg/remover  0.037s

The signature change reaches out-of-tree callers. The fork's cross-container harness calls these functions directly and stopped compiling when the context parameter was added — caught by CI, not by anything local. Nothing in this PR needed changing, but it is the concrete argument for leaving WriteScanErasePipe alone: anything outside this repo calling it keeps working untouched.

Still open, deliberately

CompletionPipe.Await takes no context and blocks the same way. It's the read side rather than the write side @ashnamehrotra asked about, and it needs the same care, so I've left it out rather than growing this PR. Happy to do it next if you'd like it.

Follow-up to eraser-dev#1231, addressing both review threads left open there.

Cancellation. WriteImagesPipe and WriteCompletionPipe now take a
context. The collector and remover derive theirs from SIGTERM, so a
terminating pod no longer leaves a worker blocked forever on a peer that
is never going to arrive, and the scanner passes the context it already
has.

The Unix rendezvous is deliberately untouched. I had proposed O_NONBLOCK
plus polling, but that changes the syscall every existing deployment
depends on, and a non-blocking descriptor then has to handle EAGAIN on
payloads larger than the pipe buffer. Instead the blocking open runs on
a goroutine that hands the file back if the caller is still waiting and
closes it if not. Linux keeps the exact open it has always used; only
the waiting becomes interruptible.

WriteCompletionPipe stats the path before opening, so an absent scanner
is still reported as ENOENT even when the context is already done. Left
to the select, that case would have been decided at random, which would
have made "scanner disabled" indistinguishable from "we are shutting
down".

WriteScanErasePipe keeps its signature for out-of-tree scanners and
waits indefinitely, as before.

Endpoint safety. listen removed whatever sat at the endpoint path before
binding. A socket left behind by an unclean exit does have to go, or a
crashed worker would poison the endpoint for every retry, but anything
else there is not ours to delete: the worker runs as NT AUTHORITY\SYSTEM
and shares the volume with a scanner image we do not control. Lstat
reports ModeSocket on Windows, so the two cases are separable.

Signed-off-by: Charles Wu <yuewu2@microsoft.com>
Copilot AI balanced review requested due to automatic review settings August 25, 2026 03:31
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 64.51613% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/remover/remover.go 0.00% 4 Missing ⚠️
pkg/utils/handoff_unix.go 86.95% 3 Missing ⚠️
pkg/collector/collector.go 0.00% 2 Missing ⚠️
pkg/scanners/template/scanner_template.go 0.00% 1 Missing ⚠️
pkg/utils/utils.go 0.00% 1 Missing ⚠️
Flag Coverage Δ
unittests 5.40% <64.51%> (-9.44%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
pkg/scanners/template/scanner_template.go 0.00% <0.00%> (ø)
pkg/utils/utils.go 19.07% <0.00%> (+7.24%) ⬆️
pkg/collector/collector.go 0.00% <0.00%> (ø)
pkg/utils/handoff_unix.go 60.71% <86.95%> (ø)
pkg/remover/remover.go 0.00% <0.00%> (ø)

... and 38 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI 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.

Pull request overview

Makes worker handoff writes context-aware and protects Windows socket paths from replacing non-socket files.

Changes:

  • Adds cancellable image and completion writes.
  • Handles SIGTERM in collector/remover.
  • Adds Windows endpoint safety and cancellation tests.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
pkg/collector/collector.go Passes a signal-aware context to handoff writes.
pkg/remover/remover.go Applies cancellation to reads and completion writes.
pkg/scanners/template/scanner_template.go Uses the configured context when sending images.
pkg/utils/handoff_unix.go Adds interruptible FIFO opening.
pkg/utils/handoff_windows.go Adds context-aware dialing and safer socket replacement.
pkg/utils/handoff_test.go Tests canceled handoff writes.
pkg/utils/platform_windows_test.go Tests occupied and stale socket handling.
pkg/utils/utils.go Preserves the legacy indefinite-write wrapper.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/utils/handoff_unix.go
err error
}

ch := make(chan opened, 1)
Comment thread pkg/remover/remover.go
// A terminating pod should not leave the worker blocked on a peer that is
// never going to arrive. The stop func is discarded rather than deferred
// because every exit path here is os.Exit, which would skip it anyway.
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
// A terminating pod should not leave the worker blocked on a peer that is
// never going to arrive. The stop func is discarded rather than deferred
// because every exit path here is os.Exit, which would skip it anyway.
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
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.

2 participants