Interrupt private request output when its context ends
2 files changed,
+189
−1
internal/provisioner/prepare_linux.go+25 −1internal/provisioner/prepare_linux_test.go+164 −0
CI passed — run details
diff --git a/internal/provisioner/prepare_linux.go b/internal/provisioner/prepare_linux.go| index 1e45a93..56de95e 100644 |
| --- a/internal/provisioner/prepare_linux.go |
| +++ b/internal/provisioner/prepare_linux.go |
| @@ -8,6 +8,7 @@ import ( |
| "net/mail" |
| "os" |
| "strconv" |
| + "time" |
| "amadan.net/oficina/home/internal/operator" |
| "github.com/go-json-experiment/json" |
| @@ -62,6 +63,11 @@ func privateRequestOutput(fd int, excluded ...int) (*requestOutput, error) { |
| return nil, ErrRefused |
| } |
| } |
| + // Pollable nonblocking ownership lets Close interrupt a blocked Write. |
| + // This descriptor is explicitly transferred to the native command. |
| + if unix.SetNonblock(fd, true) != nil { |
| + return nil, ErrRefused |
| + } |
| file := os.NewFile(uintptr(fd), "private-request") |
| if file == nil { |
| return nil, ErrRefused |
| @@ -154,7 +160,7 @@ func prepareRequest(ctx context.Context, statePath, floorPath string, password, |
| } |
| // The record and independent floor are durable before output. Failure cannot |
| // release its role slot or cause the next call to sign a replacement. |
| - if e = writePrivateRequest(output.file, raw); e != nil { |
| + if e = output.write(ctx, raw); e != nil { |
| return PreparationInfo{}, e |
| } |
| return PreparationInfo{p.Installation, p.Operation, p.Role, req.Authorization().Sequence, "local-request"}, nil |
| @@ -169,3 +175,21 @@ func writePrivateRequest(output io.Writer, raw []byte) error { |
| } |
| return nil |
| } |
| + |
| +// write keeps a stalled recipient from retaining the journal's process lock. |
| +// Closing a pollable pipe interrupts its wait without discarding the saved |
| +// request. The recipient may have a prefix, so a retry still uses exact bytes. |
| +func (o *requestOutput) write(ctx context.Context, raw []byte) error { |
| + if o == nil || o.file == nil || len(raw) == 0 || len(raw) > operator.BodyLimit { |
| + return ErrRefused |
| + } |
| + bounded, cancel := context.WithTimeout(ctx, 30*time.Second) |
| + defer cancel() |
| + stop := context.AfterFunc(bounded, func() { o.Close() }) |
| + defer stop() |
| + e := writePrivateRequest(o.file, raw) |
| + if bounded.Err() != nil { |
| + return ErrUnavailable |
| + } |
| + return e |
| +} |
diff --git a/internal/provisioner/prepare_linux_test.go b/internal/provisioner/prepare_linux_test.go| index 06ca17f..ee268a9 100644 |
| --- a/internal/provisioner/prepare_linux_test.go |
| +++ b/internal/provisioner/prepare_linux_test.go |
| @@ -4,11 +4,14 @@ package provisioner |
| import ( |
| "bytes" |
| + "context" |
| + "database/sql" |
| "io" |
| "os" |
| "os/exec" |
| "strconv" |
| "testing" |
| + "time" |
| "amadan.net/oficina/home/internal/operator" |
| "github.com/go-json-experiment/json" |
| @@ -322,3 +325,164 @@ func TestPreparationClosedPlanRefusesUnknownAndDuplicateFields(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestFullPrivatePipeCancellationKeepsExactRequest(t *testing.T) { |
| + j, p := stateFixture(t) |
| + op := testToken(23) |
| + raw, e := j.prepare(context.Background(), p.Installation, op, "directory.initialize", initialization(op)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + fds := make([]int, 2) |
| + if e = unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK); e != nil { |
| + t.Fatal(e) |
| + } |
| + defer unix.Close(fds[0]) |
| + output, e := privateRequestOutput(fds[1]) |
| + if e != nil { |
| + unix.Close(fds[1]) |
| + t.Fatal(e) |
| + } |
| + defer output.Close() |
| + fill := bytes.Repeat([]byte("x"), 4096) |
| + filled := 0 |
| + for { |
| + n, err := unix.Write(fds[1], fill) |
| + filled += n |
| + if err == unix.EAGAIN { |
| + break |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if filled <= 0 { |
| + t.Fatal("pipe was not filled") |
| + } |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + done := make(chan error, 1) |
| + go func() { done <- output.write(ctx, raw) }() |
| + select { |
| + case e := <-done: |
| + t.Fatal("full pipe did not block", e) |
| + case <-time.After(50 * time.Millisecond): |
| + } |
| + cancel() |
| + select { |
| + case e := <-done: |
| + if e != ErrUnavailable { |
| + t.Fatal("cancellation error", e) |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("cancellation did not interrupt pipe") |
| + } |
| + j.close() |
| + reopened, e := openJournal(j.dir, j.floor, j.key, false) |
| + if e != nil { |
| + t.Fatal("process lock retained", e) |
| + } |
| + defer reopened.close() |
| + repeated, e := reopened.prepare(context.Background(), p.Installation, op, "directory.initialize", initialization(op)) |
| + if e != nil || !bytes.Equal(raw, repeated) { |
| + t.Fatal("interrupted output lost exact request", e) |
| + } |
| + s := snapshot(t, reopened).installations[p.Installation] |
| + if s.Operations[op].Status != "prepared" || s.Heads["directory"].Sequence != 0 { |
| + t.Fatal("cancellation implied execution") |
| + } |
| +} |
| + |
| +func TestPrepareRequestCancelsFullPipeAndReleasesRegistry(t *testing.T) { |
| + state, floor := persistentDirectory(t), persistentDirectory(t) |
| + password := []byte("synthetic-full-pipe-password") |
| + if _, e := Initialize(state, floor, password); e != nil { |
| + t.Fatal(e) |
| + } |
| + enrollment, _ := canonicalJSON(planFixture()) |
| + if _, e := EnrollPlan(state, floor, password, enrollment); e != nil { |
| + t.Fatal(e) |
| + } |
| + p := ownerOperationPlan() |
| + plan, _ := json.Marshal(p, json.Deterministic(true)) |
| + fds := make([]int, 2) |
| + if e := unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK); e != nil { |
| + t.Fatal(e) |
| + } |
| + defer unix.Close(fds[0]) |
| + output, e := privateRequestOutput(fds[1]) |
| + if e != nil { |
| + unix.Close(fds[1]) |
| + t.Fatal(e) |
| + } |
| + defer output.Close() |
| + for { |
| + _, e = unix.Write(fds[1], bytes.Repeat([]byte("x"), 4096)) |
| + if e == unix.EAGAIN { |
| + break |
| + } |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + done := make(chan error, 1) |
| + go func() { _, e := prepareRequest(ctx, state, floor, password, plan, output); done <- e }() |
| + // Observe the immutable fixture record without joining its writer lock. |
| + observer, e := sql.Open("sqlite", "file:"+state+"/registry.db?mode=ro") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer observer.Close() |
| + deadline := time.Now().Add(30 * time.Second) |
| + for { |
| + var n int |
| + e = observer.QueryRow("SELECT count(*) FROM records WHERE operation=? AND state='finalized'", p.Operation).Scan(&n) |
| + if e == nil && n == 1 { |
| + break |
| + } |
| + select { |
| + case e := <-done: |
| + t.Fatal("preparation stopped before output", e) |
| + default: |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatal("fixture request was not persisted") |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + select { |
| + case e := <-done: |
| + t.Fatal("full output did not wait", e) |
| + case <-time.After(50 * time.Millisecond): |
| + } |
| + cancel() |
| + select { |
| + case e := <-done: |
| + if e != ErrUnavailable { |
| + t.Fatal("canceled preparation", e) |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("prepareRequest ignored cancellation") |
| + } |
| + observer.Close() |
| + j, e := existingJournal(state, floor, password) |
| + if e != nil { |
| + t.Fatal("registry lock not released", e) |
| + } |
| + defer closeExisting(j) |
| + s := snapshot(t, j).installations[p.Installation] |
| + saved := s.Operations[p.Operation] |
| + if saved == nil || saved.Status != "prepared" || s.Heads[p.Role].Sequence != 0 { |
| + t.Fatal("canceled output changed authority") |
| + } |
| + _, input, e := decodeOperationPlan(plan) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + replay, e := j.prepare(context.Background(), p.Installation, p.Operation, p.Action, input) |
| + if e != nil || !bytes.Equal(replay, saved.Raw) { |
| + t.Fatal("canceled prepare lost exact retry", e) |
| + } |
| +} |