| 1 | package hostrpc |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "reflect" |
| 10 | "runtime/debug" |
| 11 | "slices" |
| 12 | ) |
| 13 | |
| 14 | // Command is one exported method of the bound target as the shell calls it. |
| 15 | type Command struct { |
| 16 | CommandOwnership |
| 17 | Name string `json:"name"` |
| 18 | Params []TypeRef `json:"params"` |
| 19 | Result *TypeRef `json:"result,omitempty"` |
| 20 | ReturnsError bool `json:"returnsError,omitempty"` |
| 21 | Cancellation string `json:"cancellation"` |
| 22 | } |
| 23 | |
| 24 | // Skip names exported methods the registry leaves out of the contract. |
| 25 | type Skip map[string]bool |
| 26 | |
| 27 | // UnknownMethodError reports an invoke of a name the registry never accepted. |
| 28 | type UnknownMethodError struct{ Method string } |
| 29 | |
| 30 | func (e *UnknownMethodError) Error() string { return "unknown method: " + e.Method } |
| 31 | |
| 32 | // InvalidArgsError reports arguments the method cannot take. |
| 33 | type InvalidArgsError struct { |
| 34 | Method string |
| 35 | Reason string |
| 36 | } |
| 37 | |
| 38 | func (e *InvalidArgsError) Error() string { return e.Method + ": " + e.Reason } |
| 39 | |
| 40 | // PanicError carries a panic raised inside a bound method so one faulty call |
| 41 | // cannot take the whole service down. |
| 42 | type PanicError struct { |
| 43 | Method string |
| 44 | Value any |
| 45 | Stack []byte |
| 46 | } |
| 47 | |
| 48 | func (e *PanicError) Error() string { return fmt.Sprintf("%s: panic: %v", e.Method, e.Value) } |
| 49 | |
| 50 | type boundMethod struct { |
| 51 | fn reflect.Value |
| 52 | params []reflect.Type |
| 53 | result bool |
| 54 | errAt int |
| 55 | context bool |
| 56 | } |
| 57 | |
| 58 | // Registry is the set of methods the shell may invoke on one target value. |
| 59 | type Registry struct { |
| 60 | target reflect.Value |
| 61 | commands []Command |
| 62 | methods map[string]boundMethod |
| 63 | types map[string]ObjectType |
| 64 | } |
| 65 | |
| 66 | // NewRegistry reflects over the exported methods of target, which must be a |
| 67 | // pointer to a struct. A typed nil pointer yields a registry that can |
| 68 | // describe the contract but not invoke. Any method whose signature the shell |
| 69 | // could not call fails the whole registry so the surface never drifts |
| 70 | // silently; skip excludes methods by name before that check. |
| 71 | func NewRegistry(target any, skip Skip) (*Registry, error) { |
| 72 | return newRegistry(target, skip, nil) |
| 73 | } |
| 74 | |
| 75 | // NewRegistryWithOwners requires source-derived ownership for every bound |
| 76 | // command. Metadata describes the existing owner; it does not grant access. |
| 77 | func NewRegistryWithOwners(target any, skip Skip, owners map[string]CommandOwnership) (*Registry, error) { |
| 78 | if owners == nil { |
| 79 | return nil, errors.New("hostrpc: ownership metadata is required") |
| 80 | } |
| 81 | return newRegistry(target, skip, owners) |
| 82 | } |
| 83 | |
| 84 | func newRegistry(target any, skip Skip, owners map[string]CommandOwnership) (*Registry, error) { |
| 85 | if target == nil { |
| 86 | return nil, errors.New("hostrpc: nil target") |
| 87 | } |
| 88 | rt := reflect.TypeOf(target) |
| 89 | if rt.Kind() != reflect.Pointer || rt.Elem().Kind() != reflect.Struct { |
| 90 | return nil, fmt.Errorf("hostrpc: target must be a pointer to a struct, got %s", rt) |
| 91 | } |
| 92 | collector := newTypeCollector() |
| 93 | r := &Registry{ |
| 94 | target: reflect.ValueOf(target), |
| 95 | commands: []Command{}, |
| 96 | methods: map[string]boundMethod{}, |
| 97 | } |
| 98 | owner := rt.Elem().Name() |
| 99 | for i := range rt.NumMethod() { |
| 100 | m := rt.Method(i) |
| 101 | if skip[m.Name] { |
| 102 | continue |
| 103 | } |
| 104 | cmd, bound, err := describeMethod(collector, owner, m) |
| 105 | if err != nil { |
| 106 | return nil, fmt.Errorf("hostrpc: %s.%s: %w", owner, m.Name, err) |
| 107 | } |
| 108 | if owners != nil { |
| 109 | metadata, ok := owners[m.Name] |
| 110 | if !ok || metadata.Owner != owner+"."+m.Name || metadata.Domain == "" || len(metadata.Sources) == 0 || metadata.Scope.Resolver != metadata.Owner || len(metadata.Scope.Inputs) != len(cmd.Params) { |
| 111 | return nil, fmt.Errorf("hostrpc: %s.%s: missing or invalid ownership metadata", owner, m.Name) |
| 112 | } |
| 113 | kind := "owner-inputs" |
| 114 | if len(cmd.Params) == 0 { |
| 115 | kind = "owner-state" |
| 116 | } |
| 117 | if metadata.Scope.Kind != kind { |
| 118 | return nil, fmt.Errorf("hostrpc: %s.%s: invalid scope kind", owner, m.Name) |
| 119 | } |
| 120 | cmd.CommandOwnership = metadata |
| 121 | } |
| 122 | r.commands = append(r.commands, cmd) |
| 123 | r.methods[m.Name] = bound |
| 124 | } |
| 125 | r.types = collector.types |
| 126 | return r, nil |
| 127 | } |
| 128 | |
| 129 | func describeMethod(c *typeCollector, owner string, m reflect.Method) (Command, boundMethod, error) { |
| 130 | ft := m.Type |
| 131 | if ft.IsVariadic() { |
| 132 | return Command{}, boundMethod{}, errors.New("variadic parameters are not supported") |
| 133 | } |
| 134 | cmd := Command{Name: m.Name, Params: []TypeRef{}, Cancellation: "before-dispatch"} |
| 135 | bound := boundMethod{fn: m.Func, errAt: -1} |
| 136 | first := 1 |
| 137 | if ft.NumIn() > 1 && ft.In(1) == reflect.TypeFor[context.Context]() { |
| 138 | bound.context, first, cmd.Cancellation = true, 2, "cooperative-context" |
| 139 | } |
| 140 | for i := first; i < ft.NumIn(); i++ { |
| 141 | pt := ft.In(i) |
| 142 | if pt.Kind() == reflect.Interface && pt.NumMethod() > 0 { |
| 143 | return Command{}, boundMethod{}, fmt.Errorf("parameter %d: %s cannot be decoded from JSON", i-first, pt) |
| 144 | } |
| 145 | ref, err := c.ref(pt, fmt.Sprintf("%s.%s.arg%d", owner, m.Name, i-first)) |
| 146 | if err != nil { |
| 147 | return Command{}, boundMethod{}, fmt.Errorf("parameter %d: %w", i-first, err) |
| 148 | } |
| 149 | cmd.Params = append(cmd.Params, ref) |
| 150 | bound.params = append(bound.params, pt) |
| 151 | } |
| 152 | switch ft.NumOut() { |
| 153 | case 0: |
| 154 | case 1: |
| 155 | if ft.Out(0) == errorType { |
| 156 | cmd.ReturnsError, bound.errAt = true, 0 |
| 157 | break |
| 158 | } |
| 159 | if err := describeResult(c, owner, m.Name, ft.Out(0), &cmd, &bound); err != nil { |
| 160 | return Command{}, boundMethod{}, err |
| 161 | } |
| 162 | case 2: |
| 163 | if ft.Out(1) != errorType || ft.Out(0) == errorType { |
| 164 | return Command{}, boundMethod{}, fmt.Errorf("results must be (T, error), got (%s, %s)", ft.Out(0), ft.Out(1)) |
| 165 | } |
| 166 | if err := describeResult(c, owner, m.Name, ft.Out(0), &cmd, &bound); err != nil { |
| 167 | return Command{}, boundMethod{}, err |
| 168 | } |
| 169 | cmd.ReturnsError, bound.errAt = true, 1 |
| 170 | default: |
| 171 | return Command{}, boundMethod{}, fmt.Errorf("%d results; want (), (T), (error) or (T, error)", ft.NumOut()) |
| 172 | } |
| 173 | return cmd, bound, nil |
| 174 | } |
| 175 | |
| 176 | func describeResult(c *typeCollector, owner, method string, t reflect.Type, cmd *Command, bound *boundMethod) error { |
| 177 | ref, err := c.ref(t, owner+"."+method+".result") |
| 178 | if err != nil { |
| 179 | return fmt.Errorf("result: %w", err) |
| 180 | } |
| 181 | cmd.Result = &ref |
| 182 | bound.result = true |
| 183 | return nil |
| 184 | } |
| 185 | |
| 186 | // Commands lists the accepted methods sorted by name; never nil. |
| 187 | func (r *Registry) Commands() []Command { return slices.Clone(r.commands) } |
| 188 | |
| 189 | // Invoke decodes args into the method's parameters, calls it, and returns |
| 190 | // its result (nil for void) or its error. Missing trailing arguments decode |
| 191 | // as zero values, matching the retired shell's tolerance; extra ones are rejected. |
| 192 | func (r *Registry) Invoke(ctx context.Context, name string, args []json.RawMessage) (result any, err error) { |
| 193 | m, ok := r.methods[name] |
| 194 | if !ok { |
| 195 | return nil, &UnknownMethodError{Method: name} |
| 196 | } |
| 197 | if r.target.IsNil() { |
| 198 | return nil, errors.New("hostrpc: registry has no target instance") |
| 199 | } |
| 200 | if err := ctx.Err(); err != nil { |
| 201 | return nil, err |
| 202 | } |
| 203 | if len(args) > len(m.params) { |
| 204 | return nil, &InvalidArgsError{Method: name, Reason: fmt.Sprintf("expected at most %d arguments, got %d", len(m.params), len(args))} |
| 205 | } |
| 206 | in := make([]reflect.Value, 0, len(m.params)+1) |
| 207 | in = append(in, r.target) |
| 208 | if m.context { |
| 209 | in = append(in, reflect.ValueOf(ctx)) |
| 210 | } |
| 211 | for i, pt := range m.params { |
| 212 | v := reflect.New(pt) |
| 213 | if i < len(args) && len(bytes.TrimSpace(args[i])) > 0 { |
| 214 | if err := json.Unmarshal(args[i], v.Interface()); err != nil { |
| 215 | return nil, &InvalidArgsError{Method: name, Reason: fmt.Sprintf("argument %d: %v", i, err)} |
| 216 | } |
| 217 | } |
| 218 | in = append(in, v.Elem()) |
| 219 | } |
| 220 | defer func() { |
| 221 | if rec := recover(); rec != nil { |
| 222 | result, err = nil, &PanicError{Method: name, Value: rec, Stack: debug.Stack()} |
| 223 | } |
| 224 | }() |
| 225 | // Decoders may take time or trigger cancellation. This is the final |
| 226 | // cancellation boundary for synchronous methods; never discard their |
| 227 | // result after dispatch, since a write may already have committed. |
| 228 | if err := ctx.Err(); err != nil { |
| 229 | return nil, err |
| 230 | } |
| 231 | out := m.fn.Call(in) |
| 232 | if m.errAt >= 0 { |
| 233 | if callErr, _ := out[m.errAt].Interface().(error); callErr != nil { |
| 234 | return nil, callErr |
| 235 | } |
| 236 | } |
| 237 | if m.result { |
| 238 | return out[0].Interface(), nil |
| 239 | } |
| 240 | return nil, nil |
| 241 | } |
| 242 |