diff --git a/internal/tigerfs/fuse/adapter.go b/internal/tigerfs/fuse/adapter.go index ca7e5df..a891954 100644 --- a/internal/tigerfs/fuse/adapter.go +++ b/internal/tigerfs/fuse/adapter.go @@ -224,9 +224,13 @@ func (a *FSAdapter) EntriesToDirEntries(entries []tigerfs.Entry) []gofuse.DirEnt // which fails in-flight queries. We only lose the per-request // cancellation channel, which the bug shows we don't actually want. // -// Write paths intentionally do NOT use this -- partial writes have -// real consistency implications when interrupted, and the request-ctx -// cancellation surface is the right tool there. +// Applies to both read and write paths. Each tigerfs write op runs as a +// single auto-committed statement (or a single transaction wrapping a +// few atomic statements -- e.g., DeleteAndUpdate for POSIX rename-as- +// replace). Either the whole statement/transaction commits or none of +// it commits; there is no partial state to leak when we ignore the +// kernel's transient cancellation. The kernel's FUSE_INTERRUPT means +// "you may stop early if you want," not "abort, the syscall is dying." func decoupleFromRequestCancel(ctx context.Context) context.Context { return context.WithoutCancel(ctx) } @@ -305,6 +309,7 @@ func (a *FSAdapter) ReadFile(ctx context.Context, path string) ([]byte, syscall. // // Returns errno (0 on success). func (a *FSAdapter) WriteFile(ctx context.Context, path string, data []byte) syscall.Errno { + ctx = decoupleFromRequestCancel(ctx) fsErr := a.ops.WriteFile(ctx, path, data) return a.ErrorToErrno(fsErr) } @@ -319,6 +324,7 @@ func (a *FSAdapter) WriteFile(ctx context.Context, path string, data []byte) sys // // Returns errno (0 on success). func (a *FSAdapter) Delete(ctx context.Context, path string) syscall.Errno { + ctx = decoupleFromRequestCancel(ctx) fsErr := a.ops.Delete(ctx, path) return a.ErrorToErrno(fsErr) } @@ -334,6 +340,7 @@ func (a *FSAdapter) Delete(ctx context.Context, path string) syscall.Errno { // // Returns errno (0 on success). func (a *FSAdapter) Mkdir(ctx context.Context, path string) syscall.Errno { + ctx = decoupleFromRequestCancel(ctx) fsErr := a.ops.Mkdir(ctx, path) return a.ErrorToErrno(fsErr) } @@ -350,6 +357,7 @@ func (a *FSAdapter) Mkdir(ctx context.Context, path string) syscall.Errno { // // Returns errno (0 on success). func (a *FSAdapter) Rename(ctx context.Context, oldPath, newPath string) syscall.Errno { + ctx = decoupleFromRequestCancel(ctx) fsErr := a.ops.Rename(ctx, oldPath, newPath) return a.ErrorToErrno(fsErr) } diff --git a/internal/tigerfs/fuse/adapter_ctx_test.go b/internal/tigerfs/fuse/adapter_ctx_test.go index 2b4141d..8a7c151 100644 --- a/internal/tigerfs/fuse/adapter_ctx_test.go +++ b/internal/tigerfs/fuse/adapter_ctx_test.go @@ -88,6 +88,87 @@ func TestFSAdapter_ReadFile_DecouplesRequestCancel(t *testing.T) { } } +// Write-path coverage. Symmetric to the read-path tests: each FSAdapter +// write method must also decouple the request ctx so FUSE_INTERRUPT +// (typically from Go SIGURG goroutine preemption) doesn't surface as +// close-time EIO / rename ENOENT / delete ENOENT on otherwise valid ops. + +func TestFSAdapter_WriteFile_DecouplesRequestCancel(t *testing.T) { + capturedCtx := captureCtxViaWriteFile(t, "/public/anything") + if capturedCtx == nil { + t.Fatal("Operations was not reached; FSAdapter short-circuited (regression)") + } + if capturedCtx.Err() != nil { + t.Errorf("FSAdapter forwarded cancelled ctx to DB layer: %v", capturedCtx.Err()) + } +} + +func TestFSAdapter_Delete_DecouplesRequestCancel(t *testing.T) { + capturedCtx := captureCtxViaDelete(t, "/public/anything") + if capturedCtx == nil { + t.Fatal("Operations was not reached; FSAdapter short-circuited (regression)") + } + if capturedCtx.Err() != nil { + t.Errorf("FSAdapter forwarded cancelled ctx to DB layer: %v", capturedCtx.Err()) + } +} + +func TestFSAdapter_Mkdir_DecouplesRequestCancel(t *testing.T) { + capturedCtx := captureCtxViaMkdir(t, "/public/anything") + if capturedCtx == nil { + t.Fatal("Operations was not reached; FSAdapter short-circuited (regression)") + } + if capturedCtx.Err() != nil { + t.Errorf("FSAdapter forwarded cancelled ctx to DB layer: %v", capturedCtx.Err()) + } +} + +func TestFSAdapter_Rename_DecouplesRequestCancel(t *testing.T) { + capturedCtx := captureCtxViaRename(t, "/public/old", "/public/new") + if capturedCtx == nil { + t.Fatal("Operations was not reached; FSAdapter short-circuited (regression)") + } + if capturedCtx.Err() != nil { + t.Errorf("FSAdapter forwarded cancelled ctx to DB layer: %v", capturedCtx.Err()) + } +} + +func captureCtxViaWriteFile(t *testing.T, path string) context.Context { + t.Helper() + adapter, captured := newAdapterWithCtxCapture() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _ = adapter.WriteFile(ctx, path, []byte("data")) + return *captured +} + +func captureCtxViaDelete(t *testing.T, path string) context.Context { + t.Helper() + adapter, captured := newAdapterWithCtxCapture() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _ = adapter.Delete(ctx, path) + return *captured +} + +func captureCtxViaMkdir(t *testing.T, path string) context.Context { + t.Helper() + adapter, captured := newAdapterWithCtxCapture() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _ = adapter.Mkdir(ctx, path) + return *captured +} + +func captureCtxViaRename(t *testing.T, oldPath, newPath string) context.Context { + t.Helper() + adapter, captured := newAdapterWithCtxCapture() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _ = adapter.Rename(ctx, oldPath, newPath) + return *captured +} + // captureCtxViaReadDir constructs an FSAdapter wired to a MockDBClient // that records the ctx given to GetCurrentSchema (the earliest DB call // Operations.ReadDir makes), invokes ReadDir with an already-cancelled