Skip to content

Close workflow audit log writers - #6110

Open
kocaemre wants to merge 1 commit into
stacklok:mainfrom
kocaemre:fix/workflow-auditor-close-file
Open

Close workflow audit log writers#6110
kocaemre wants to merge 1 commit into
stacklok:mainfrom
kocaemre:fix/workflow-auditor-close-file

Conversation

@kocaemre

Copy link
Copy Markdown
Contributor

Summary

  • Workflow audit logging can currently leak a file descriptor when Config.LogFile is set because NewWorkflowAuditor opens a log writer but the returned auditor does not retain or expose a way to close it.
  • Retain the workflow auditor log writer, add WorkflowAuditor.Close(), and close the optional workflow auditor from coreVMCP.Close() and core construction error paths.
  • Share close handling with the HTTP auditor and avoid closing os.Stdout/os.Stderr when audit logging uses default output.

Fixes #6094

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Manual testing:

  • PATH=/usr/local/go/bin:/root/go/bin:$PATH task lint
  • PATH=/usr/local/go/bin:/root/go/bin:$PATH go test -race ./pkg/audit ./pkg/vmcp/core
  • PATH=/usr/local/go/bin:/root/go/bin:$PATH go test ./pkg/vmcp/composer

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Changes

File Change
pkg/audit/auditor.go Reuse a shared close helper and avoid closing stdout/stderr.
pkg/audit/workflow_auditor.go Retain the log writer and expose Close().
pkg/audit/workflow_auditor_test.go Cover file-backed close, stdout handling, and close error propagation.
pkg/vmcp/core/core_vmcp.go Close workflow auditor resources during shutdown and constructor cleanup paths.

Does this introduce a user-facing change?

No. This releases an internal audit log file descriptor when the workflow auditor owner is closed.

@jerm-dro jerm-dro 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.

Thanks for taking this on — the diagnosis and the design are right. Retaining the writer, factoring out the shared close helper, and catching the stdout trap rather than inheriting it are all exactly what #6094 asked for, and putting the auditor's lifecycle on coreVMCP (next to stopStore and healthMonitor) is the correct owner.

Two blockers, both mechanical, both about the tests rather than the fix:

  1. The two stdout tests pass against the unfixed code — os.Stdout.Close() returns nil, so require.NoError can't distinguish fixed from broken. As written they'd stay green if the guard were removed.
  2. The coreVMCP change is the code that actually plugs the leak, and it has no coverage — including the three New error paths.

Details and suggested code in the line comments.

Comment thread pkg/audit/workflow_auditor_test.go Outdated
Comment on lines +180 to +186
func TestAuditor_CloseDoesNotCloseStdout(t *testing.T) {
t.Parallel()

auditor := &Auditor{logWriter: os.Stdout}

require.NoError(t, auditor.Close())
}

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.

blocker: This test passes against the unfixed code, so it can't protect the guard it's guarding.

os.Stdout.Close() succeeds — it closes fd 1 and returns nil. So require.NoError(t, auditor.Close()) is satisfied whether or not closeLogWriter skips stdout. Delete the guard in auditor.go and this test still goes green.

There's a second-order hazard: the test is t.Parallel(), so if the guard ever regresses, this test closes fd 1 for the whole test binary while other tests in the package are running — they'd fail confusingly and this one would report a pass.

The assertion has to observe the descriptor, not the error:

func TestAuditor_CloseDoesNotCloseStdout(t *testing.T) {
	t.Parallel()

	auditor := &Auditor{logWriter: os.Stdout}
	require.NoError(t, auditor.Close())

	// The point of the guard: fd 1 must still be usable afterwards.
	// Write(nil) touches the descriptor without emitting output.
	_, err := os.Stdout.Write(nil)
	require.NoError(t, err, "Close() must not close os.Stdout")
}

On the fixed code Write(nil) returns nil; on the unfixed code it returns a non-nil error (file already closed). I verified both directions.

The same change is needed in the does not close stdout subtest of TestWorkflowAuditor_Close above — assert.Same(t, os.Stdout, auditor.logWriter) only proves the field was retained, not that the fd survived.

(Minor, while you're here: this test covers Auditor, not WorkflowAuditor — it belongs in auditor_test.go.)

Comment on lines +562 to +566
if c.workflowAuditor != nil {
if err := c.workflowAuditor.Close(); err != nil {
slog.Warn("failed to close workflow auditor", "error", err)
}
}

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.

blocker: This is the file that actually fixes #6094, and it's the only one in the diff with no test coverage. All three new tests exercise WorkflowAuditor.Close() directly in pkg/audit; nothing asserts that a core built with a file-backed AuditConfig releases the descriptor, and nothing covers the three New error paths where closeWorkflowAuditor() was added. Those manual, repeated cleanup calls are exactly what a future refactor drops.

pkg/vmcp/core already has the harness — baseConfig(t) in core_vmcp_test.go:46 plus the t.Cleanup(func() { _ = c.Close() }) pattern used throughout core_backends_test.go:

func TestNew_CloseReleasesWorkflowAuditLogFile(t *testing.T) {
	t.Parallel()

	cfg, _ := baseConfig(t)
	cfg.AuditConfig = &audit.Config{LogFile: filepath.Join(t.TempDir(), "audit.log")}

	c, err := New(cfg)
	require.NoError(t, err)
	require.NoError(t, c.Close())

	// A second close on the same *os.File reports ErrClosed, proving the
	// first one reached the descriptor rather than silently no-opping.
	require.ErrorIs(t, c.(*coreVMCP).workflowAuditor.Close(), os.ErrClosed)
}

If you'd rather keep it to one test, please at least cover a single New error path (e.g. forcing validateWorkflowDefs to fail) and assert the fd was released — that's the regression the cleanup ladder exists to prevent.

@kocaemre
kocaemre force-pushed the fix/workflow-auditor-close-file branch from 46bd4b0 to 812b3ed Compare August 15, 2026 17:38
@kocaemre

Copy link
Copy Markdown
Contributor Author

Addressed the two mechanical blockers in 812b3ed7.

Changes:

  • moved the Auditor stdout regression into auditor_test.go and made it observe fd usability with os.Stdout.Write(nil) after Close();
  • updated the WorkflowAuditor stdout subtest to assert the stdout descriptor remains usable after Close() too;
  • added core-level coverage for file-backed workflow audit lifecycle:
    • coreVMCP.Close() reaches and closes the workflow auditor's retained file descriptor;
    • New() closes the workflow audit file on the workflow-validation error path;
    • New() closes the workflow audit file on the health-monitor creation error path.

Local verification:

  • go test -ldflags=-extldflags=-Wl,-w -run 'Test(Auditor_CloseDoesNotCloseStdout|WorkflowAuditor_Close)$' ./pkg/audit → passed
  • go test -ldflags=-extldflags=-Wl,-w -run 'TestNew_(CloseReleasesWorkflowAuditLogFile|ErrorPathsCloseWorkflowAuditLogFile|ValidatesWorkflows)$' ./pkg/vmcp/core → passed
  • task lint → passed (golangci-lint + go vet, 0 issues)

I also started task test; it is still running locally because it invokes the full race-enabled unit suite across the repo.

@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.99%. Comparing base (cd373ec) to head (17d4e5b).
⚠️ Report is 124 commits behind head on main.

Files with missing lines Patch % Lines
pkg/vmcp/core/core_vmcp.go 75.00% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6110      +/-   ##
==========================================
+ Coverage   72.37%   72.99%   +0.62%     
==========================================
  Files         733      742       +9     
  Lines       75804    78508    +2704     
==========================================
+ Hits        54860    57305    +2445     
- Misses      17046    17211     +165     
- Partials     3898     3992      +94     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

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

@kocaemre

Copy link
Copy Markdown
Contributor Author

Follow-up on the local task test run I mentioned above: the full race-enabled suite eventually failed in two integration tests outside this PR's touched packages/code paths:

  • pkg/transport/proxy/streamable: TestPostSSE_ProgressIsolationBetweenSessions
  • pkg/vmcp/server: TestIntegration_SSEGetConnectionSurvivesWriteTimeout

I re-ran both failures in isolation and at package scope with the same race/ldflags shape, and they passed:

  • go test -ldflags=-extldflags=-Wl,-w -race -count=3 -run '^TestPostSSE_ProgressIsolationBetweenSessions$' ./pkg/transport/proxy/streamable → passed
  • go test -ldflags=-extldflags=-Wl,-w -race ./pkg/transport/proxy/streamable → passed
  • go test -ldflags=-extldflags=-Wl,-w -race -count=3 -run '^TestIntegration_SSEGetConnectionSurvivesWriteTimeout$' ./pkg/vmcp/server → passed
  • go test -ldflags=-extldflags=-Wl,-w -race ./pkg/vmcp/server → passed

So I don't see evidence that those two full-suite failures are caused by the workflow-auditor close changes.

Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com>
@kocaemre
kocaemre force-pushed the fix/workflow-auditor-close-file branch from 812b3ed to 17d4e5b Compare August 18, 2026 20:40
@kocaemre

Copy link
Copy Markdown
Contributor Author

Added one more focused core close-path regression in 17d4e5ba.

The new subtest pre-closes the retained workflow auditor and then calls coreVMCP.Close(), so the close-error logging branch in core_vmcp.go is now exercised without making Close() fail. Local coverage for coreVMCP.Close moved from 77.8% to 88.9%, and the previously uncovered close-error block is covered (core_vmcp.go:563.52,565.5 count 1).

Verification:

  • go test -ldflags=-extldflags=-Wl,-w -run 'Test(Auditor_CloseDoesNotCloseStdout|WorkflowAuditor_Close)$' ./pkg/audit → passed\n- go test -ldflags=-extldflags=-Wl,-w -run 'TestNew_(CloseReleasesWorkflowAuditLogFile|ErrorPathsCloseWorkflowAuditLogFile|ValidatesWorkflows)$' ./pkg/vmcp/core → passed\n- go test -ldflags=-extldflags=-Wl,-w -coverprofile=/tmp/core6110-after.cover ./pkg/vmcp/core → passed, 87.6% package coverage\n- task lint → passed (golangci-lint + go vet, 0 issues)\n- git diff --check origin/main..HEAD → passed

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.

WorkflowAuditor leaks the audit log file descriptor

2 participants