Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions api/context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ type (
Close() error
}

// FrameAttachment is a frame-owned resource produced while resolving spawn
// options. Rollback releases a reservation when process admission does not
// transfer it to a frame. It must be safe after Close and on repeated calls.
FrameAttachment interface {
Closer
Rollback() error
}

// CloserFunc is a function that implements Closer interface.
CloserFunc func() error

Expand Down
66 changes: 58 additions & 8 deletions api/context/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,42 @@ type frameResolverEntry struct {
// Resolve still fails closed when the options bag contains a globally claimed
// frame option that no registered resolver covers.
type FrameResolvers struct {
snapshot atomic.Pointer[[]frameResolverEntry]
mu sync.Mutex // guards Register's copy-on-write
snapshot atomic.Pointer[[]frameResolverEntry]
claimSnapshot atomic.Pointer[map[string]FrameResolverClaim]
mu sync.Mutex // guards registration copy-on-write
}

// NewFrameResolvers returns an empty registry.
func NewFrameResolvers() *FrameResolvers { return &FrameResolvers{} }

// RegisterClaim reserves a frame option on this resolver registry. Claims are
// installed by the boot composition root so subsystem packages do not need
// import-time registration. A selected claim without a covering resolver makes
// Resolve fail closed.
func (r *FrameResolvers) RegisterClaim(name string, selected FrameResolverClaim) error {
if name == "" {
return errors.New("frame resolver claim name cannot be empty")
}
if selected == nil {
return fmt.Errorf("frame resolver claim %q: nil function", name)
}
r.mu.Lock()
defer r.mu.Unlock()
next := map[string]FrameResolverClaim{name: selected}
if current := r.claimSnapshot.Load(); current != nil {
next = make(map[string]FrameResolverClaim, len(*current)+1)
for key, claim := range *current {
if key == name {
return fmt.Errorf("frame resolver claim %q already registered", name)
}
next[key] = claim
}
next[name] = selected
}
r.claimSnapshot.Store(&next)
return nil
}

// RegisterFrameResolverClaim claims a frame-context selection. If selected
// returns true during dispatch but no resolver registered for this name, Resolve
// returns ErrFrameResolverNotRegistered instead of silently ignoring it.
Expand Down Expand Up @@ -139,27 +168,38 @@ func (r *FrameResolvers) Register(name string, order int, fn FrameResolver, clai
// atomically.
func (r *FrameResolvers) Resolve(ctx context.Context, options attrs.Attributes, pairs []Pair) ([]Pair, error) {
if r == nil {
return pairs, validateFrameResolverClaims(ctx, options, nil)
return pairs, validateFrameResolverClaims(ctx, options, nil, nil)
}
cur := r.snapshot.Load()
if cur == nil {
return pairs, validateFrameResolverClaims(ctx, options, nil)
return pairs, validateFrameResolverClaims(ctx, options, nil, r.claimSnapshot.Load())
}
if err := validateFrameResolverClaims(ctx, options, *cur); err != nil {
if err := validateFrameResolverClaims(ctx, options, *cur, r.claimSnapshot.Load()); err != nil {
return nil, err
}
base := len(pairs)
for _, e := range *cur {
got, err := e.fn(ctx, options)
if err != nil {
return nil, fmt.Errorf("frame resolver %q: %w", e.name, err)
rollbackErr := errors.Join(
rollbackFramePairs(got),
rollbackFramePairs(pairs[base:]),
)
return nil, fmt.Errorf("frame resolver %q: %w", e.name, errors.Join(err, rollbackErr))
}
pairs = append(pairs, got...)
}
return pairs, nil
}

func validateFrameResolverClaims(ctx context.Context, options attrs.Attributes, entries []frameResolverEntry) error {
claims := frameClaims.Load()
func validateFrameResolverClaims(ctx context.Context, options attrs.Attributes, entries []frameResolverEntry, local *map[string]FrameResolverClaim) error {
if err := validateClaimSet(ctx, options, entries, local); err != nil {
return err
}
return validateClaimSet(ctx, options, entries, frameClaims.Load())
}

func validateClaimSet(ctx context.Context, options attrs.Attributes, entries []frameResolverEntry, claims *map[string]FrameResolverClaim) error {
if claims == nil {
return nil
}
Expand All @@ -175,6 +215,16 @@ func validateFrameResolverClaims(ctx context.Context, options attrs.Attributes,
return nil
}

func rollbackFramePairs(pairs []Pair) error {
var result error
for index := len(pairs) - 1; index >= 0; index-- {
if attachment, ok := pairs[index].Value.(FrameAttachment); ok {
result = errors.Join(result, attachment.Rollback())
}
}
return result
}

func frameResolverClaimCovered(entries []frameResolverEntry, name string) bool {
for _, e := range entries {
if e.name == name {
Expand Down
64 changes: 64 additions & 0 deletions api/context/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ var (
testFrameClaimSeq atomic.Uint64
)

type testFrameAttachment struct {
closed *int
err error
once sync.Once
}

func (a *testFrameAttachment) release() error {
a.once.Do(func() { *a.closed++ })
return a.err
}
func (a *testFrameAttachment) Close() error { return a.release() }
func (a *testFrameAttachment) Rollback() error { return a.release() }

func testFrameResolverClaimName(t *testing.T) string {
t.Helper()
return "test.claim." + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + "." + strconv.FormatUint(testFrameClaimSeq.Add(1), 10)
Expand Down Expand Up @@ -164,6 +177,57 @@ func TestFrameResolvers_FirstErrorStopsAndWraps(t *testing.T) {
assert.Contains(t, err.Error(), "bad", "error must name the failing resolver")
}

func TestFrameResolvers_FirstErrorClosesProducedPairs(t *testing.T) {
r := NewFrameResolvers()
closed := 0
released := &testFrameAttachment{closed: &closed}
failed := &testFrameAttachment{closed: &closed}
require.NoError(t, r.Register("resource", 10, func(context.Context, attrs.Attributes) ([]Pair, error) {
return []Pair{{Key: &Key{Name: "resource"}, Value: released}}, nil
}))
require.NoError(t, r.Register("failure", 20, func(context.Context, attrs.Attributes) ([]Pair, error) {
return []Pair{{Key: &Key{Name: "failed"}, Value: failed}}, errors.New("resolve failed")
}))

out, err := r.Resolve(context.Background(), nil, nil)
require.Error(t, err)
require.Nil(t, out)
require.Equal(t, 2, closed, "all resolver-owned values must roll back when resolution fails")
}

func TestFrameResolvers_PreservesRollbackFailure(t *testing.T) {
resolveErr := errors.New("resolve failed")
rollbackErr := errors.New("rollback failed")
closed := 0
r := NewFrameResolvers()
require.NoError(t, r.Register("resource", 10, func(context.Context, attrs.Attributes) ([]Pair, error) {
return []Pair{{Key: &Key{Name: "resource"}, Value: &testFrameAttachment{
closed: &closed, err: rollbackErr,
}}}, nil
}))
require.NoError(t, r.Register("failure", 20, func(context.Context, attrs.Attributes) ([]Pair, error) {
return nil, resolveErr
}))

_, err := r.Resolve(context.Background(), nil, nil)
require.ErrorIs(t, err, resolveErr)
require.ErrorIs(t, err, rollbackErr)
require.Equal(t, 1, closed)
}

func TestFrameResolvers_InstanceClaimFailsClosed(t *testing.T) {
r := NewFrameResolvers()
require.NoError(t, r.RegisterClaim("terminal", func(_ context.Context, options attrs.Attributes) bool {
return options != nil && options.GetString("terminal", "") != ""
}))
options := attrs.NewBag()
options.Set("terminal", "grant")

out, err := r.Resolve(context.Background(), options, nil)
require.ErrorIs(t, err, ErrFrameResolverNotRegistered)
require.Nil(t, out)
}

func TestFrameResolvers_RegisterRejectsDuplicateAndNil(t *testing.T) {
r := NewFrameResolvers()
_, fn := pairResolver(10, &Key{Name: "k"}, "v")
Expand Down
9 changes: 9 additions & 0 deletions api/process/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ type (
Terminate(ctx context.Context, p pid.PID) error
}

// FrameAttachmentHost is implemented by local hosts that transfer resolved
// frame attachments into the lifetime of the newly admitted process frame.
// Durable or remote hosts must not claim this capability unless they provide
// an equivalent ownership boundary.
FrameAttachmentHost interface {
Host
AcceptsFrameAttachments() bool
}

// Manager defines the interface for process lifecycle management.
Manager interface {
Start(ctx context.Context, start *Start) (pid.PID, error)
Expand Down
60 changes: 58 additions & 2 deletions api/service/exec/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,51 @@ const (
// ProcessOptions defines options for creating a new process
type ProcessOptions struct {
Env map[string]string
PTY *PTYOptions
WorkDir string
}

type PTYOptions struct {
Term string
Width int
Height int
}

const (
DefaultPTYWidth = 80
DefaultPTYHeight = 24
MaxPTYDimension = 65535
MaxPTYCells = 1 << 18
)

// ValidatePTYSize bounds both terminal coordinates and the backing screen.
func ValidatePTYSize(width, height int) error {
if width < 1 || width > MaxPTYDimension || height < 1 || height > MaxPTYDimension ||
height > MaxPTYCells/width {
return ErrInvalidPTYSize
}
return nil
}

// Dimensions returns a validated initial terminal size. Zero values select
// the conventional 80x24 default.
func (o *PTYOptions) Dimensions() (int, int, error) {
width, height := DefaultPTYWidth, DefaultPTYHeight
if o == nil {
return width, height, nil
}
if o.Width != 0 {
width = o.Width
}
if o.Height != 0 {
height = o.Height
}
if err := ValidatePTYSize(width, height); err != nil {
return 0, 0, err
}
return width, height, nil
}

// ProcessExecutor defines the interface for process execution
type ProcessExecutor interface {
// NewProcess creates a new process with the given command and options
Expand All @@ -41,12 +83,26 @@ type Process interface {
// WriteStdin writes data to the process stdin
WriteStdin(data []byte) error

// Stdout returns a reader for the process stdout
// Stdout returns the process stdout reader. A caller that acquires a non-nil
// reader owns its final drain and close.
Stdout() io.ReadCloser

// Stderr returns a reader for the process stderr
// Stderr returns the process stderr reader. A caller that acquires a non-nil
// reader owns its final drain and close.
Stderr() io.ReadCloser

// Wait waits for the process to complete
Wait() error
}

// PTYProcess is the capability exposed only by PTY-backed processes.
type PTYProcess interface {
Process
Resize(width, height int) error
}

// WaitCanceler is an optional lifecycle capability for remote executors whose
// Wait operation can otherwise outlive an abandoned proxy or runtime process.
type WaitCanceler interface {
CancelWait()
}
14 changes: 14 additions & 0 deletions api/service/exec/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ func TestKindConstants(t *testing.T) {
assert.Equal(t, "exec.native", NativeExecutor)
}

func TestPTYDimensionsDefaultAndValidation(t *testing.T) {
width, height, err := (&PTYOptions{}).Dimensions()
require.NoError(t, err)
assert.Equal(t, DefaultPTYWidth, width)
assert.Equal(t, DefaultPTYHeight, height)

_, _, err = (&PTYOptions{Width: -1}).Dimensions()
assert.ErrorIs(t, err, ErrInvalidPTYSize)
_, _, err = (&PTYOptions{Height: MaxPTYDimension + 1}).Dimensions()
assert.ErrorIs(t, err, ErrInvalidPTYSize)
_, _, err = (&PTYOptions{Width: MaxPTYCells, Height: 2}).Dimensions()
assert.ErrorIs(t, err, ErrInvalidPTYSize)
}

func TestProcessOptions_MarshalUnmarshal(t *testing.T) {
tests := []struct {
options ProcessOptions
Expand Down
8 changes: 8 additions & 0 deletions api/service/exec/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,11 @@ import apierror "github.com/wippyai/runtime/api/error"

// ErrImageRequired indicates a missing container image.
var ErrImageRequired = apierror.New(apierror.Invalid, "docker image is required").WithRetryable(apierror.False)

var ErrPTYUnavailable = apierror.New(apierror.Unavailable, "PTY is unavailable").WithRetryable(apierror.False)

var ErrInvalidPTYSize = apierror.New(apierror.Invalid, "PTY dimensions must be positive, at most 65535, and within the cell limit").WithRetryable(apierror.False)

var ErrCommandRequired = apierror.New(apierror.Invalid, "command is required").WithRetryable(apierror.False)

var ErrInvalidCommand = apierror.New(apierror.Invalid, "invalid command").WithRetryable(apierror.False)
Loading