Assets Resource Leak Fix Specification¶
- Authors
- Matt Cockayne, Claude (claude-opus-4-6) (AI drafting assistant)
- Date
- 21 March 2026
- Status
- DRAFT
Overview¶
pkg/props/assets.go:243โ295 contains a resource leak in the openMergedCSV method. A defer f.Close() statement inside a for loop means all file handles opened during iteration remain open until the function returns, rather than being closed per iteration. With many registered filesystems, this can exhaust file descriptors.
for _, fsName := range a.order {
f, err := ef.Open(name)
// ...
defer func() { _ = f.Close() }() // BUG: all defers stack until return
// ...
}
Design Decisions¶
Extract helper function: Move the per-filesystem CSV reading into a helper method. The helper's defer fires on each call, ensuring files are closed promptly. This is cleaner than manual f.Close() calls with error-path bookkeeping.
Public API Changes¶
None. This is an internal bug fix with no public API impact.
Internal Implementation¶
New Helper: readCSVFromFS¶
// readCSVFromFS reads all CSV rows from a single filesystem.
// The file is closed before this function returns.
func (a *embeddedAssets) readCSVFromFS(ef fs.FS, name string) ([][]string, error) {
f, err := ef.Open(name)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
reader := csv.NewReader(f)
return reader.ReadAll()
}
Updated openMergedCSV¶
func (a *embeddedAssets) openMergedCSV(name string) (fs.File, error) {
var allRows [][]string
found := false
for _, fsName := range a.order {
ef := a.embedded[fsName]
if ef == nil {
continue
}
rows, err := a.readCSVFromFS(ef, name)
if err != nil || len(rows) == 0 {
continue
}
if !found {
allRows = rows
} else {
allRows = append(allRows, rows...)
}
found = true
}
if !found {
return nil, fs.ErrNotExist
}
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
_ = writer.WriteAll(allRows)
writer.Flush()
return &mergedFile{
name: name,
Reader: bytes.NewReader(buf.Bytes()),
}, nil
}
Project Structure¶
pkg/props/
โโโ assets.go โ MODIFIED: extract readCSVFromFS, fix openMergedCSV
โโโ assets_test.go โ MODIFIED: add resource leak test
Testing Strategy¶
| Test | Scenario |
|---|---|
TestOpenMergedCSV_SingleFS |
One FS with CSV โ returns rows correctly |
TestOpenMergedCSV_MultipleFS |
Three FSs with CSV โ rows merged correctly |
TestOpenMergedCSV_NotFound |
No FS contains the CSV โ returns fs.ErrNotExist |
TestOpenMergedCSV_EmptyCSV |
FS contains empty CSV โ skipped |
TestOpenMergedCSV_FilesClosedPromptly |
Use a spy FS that tracks Close() calls โ all files closed before function returns |
Spy FS for Close Tracking¶
type spyFile struct {
fs.File
closed *atomic.Bool
}
func (f *spyFile) Close() error {
f.closed.Store(true)
return f.File.Close()
}
Coverage¶
- Target: 90%+ for
pkg/props/.
Linting¶
golangci-lint run --fixmust pass.- The fix resolves any
gocritic/deferInLoopwarnings on this code path. - No new
nolintdirectives.
Documentation¶
- Godoc for new
readCSVFromFShelper method. - No user-facing documentation changes.
Backwards Compatibility¶
- No breaking changes. Behaviour is identical โ files are just closed sooner.
Future Considerations¶
- Streaming CSV merge: For very large CSVs, a streaming merge that doesn't load all rows into memory could be added. Out of scope for this fix.
Implementation Phases¶
Phase 1 โ Fix¶
- Add
readCSVFromFShelper method - Refactor
openMergedCSVto use the helper - Remove the
deferfrom the loop
Phase 2 โ Tests¶
- Add the spy FS test verifying prompt closure
- Add edge case tests (empty, missing, multiple)
- Run with race detector
Verification¶
go build ./...
go test -race ./pkg/props/...
go test ./...
golangci-lint run --fix
# Verify no defer-in-loop remains
grep -A2 'for.*range' pkg/props/assets.go | grep 'defer' # should return nothing