Follow the packet from the caller to the return value. A Designer Jython call enters ClientScripts.multiply, crosses the client-side dynamic proxy created by ModuleRPCFactory, reaches the gateway servlet, selects the module by module ID, selects a method on the object returned by GatewayHook.getRPCHandler, and serializes that method’s result back to the caller. The failing branch stops during response serialization: it attempts to return a live GatewayScripts object instead of returning the multiplication result.
Where Does the RPC Request Travel?
Layer one first. Confirm that the client or Designer can reach its configured gateway before changing Java dispatch code. RPC cannot repair a disconnected network interface, broken route, failed gateway session, or unavailable gateway process. Use the existing gateway connection state and transport diagnostics; the module does not need a separately invented RPC address or port.
| Path stage | Selector or reading | Successful outcome | Failure meaning |
|---|---|---|---|
| Physical and network path | Active gateway connection | The client or Designer exchanges requests with the configured gateway | Stop before RPC diagnosis and repair connectivity |
| Client script module | system.lvcontrol |
The call reaches ClientScripts
|
Script registration or scope is wrong |
| Module routing | com.lvcontrol.ignition |
The gateway locates the module | The proxy uses the wrong module ID or the module is unavailable |
| Handler selection | Object returned by getRPCHandler
|
The gateway has one root dispatch object | The hook returned no usable handler |
| Method dispatch | Reflected method name and arguments | The matching root method executes | The interface, name, argument types, or deployed classes disagree |
| Response path | Returned object graph | The result can cross the RPC serialization boundary | A gateway implementation object or other unsupported value was returned |
The first routing key is the module ID. Once the module is selected, the RPC engine resolves the requested method on the root handler through reflection and passes its arguments. That method may execute work directly or delegate to another gateway-side class. Delegation remains inside the gateway process; returning the delegate changes the operation into an attempted remote-object transfer.
Does a Working square Call Clear the Network Path?
Use square as the control measurement. In the reported implementation, rpc.square(value) works while rpc.getScripts().multiply(val1, val2) fails. Both begin through the same proxy and module ID, so the successful direct call clears several branches at once: script registration is active, the module ID resolves, the gateway supplies an RPC handler, the method can be found, and a double result returns successfully.
| Observed call | Result | Diagnostic conclusion | Next check |
|---|---|---|---|
rpc.square(value) |
Returns a value | The common route and scalar response path work | Compare the failing method’s return type and call shape |
rpc.getScripts().multiply(val1, val2) |
UndeclaredThrowableException |
The proxy wrapped a checked gateway failure | Read the deepest caused-by entry |
getScripts() response |
ClassCastException against java.io.Serializable
|
The gateway attempted to serialize GatewayScripts
|
Keep the service object gateway-side and return data |
Do not stop at the outer java.lang.reflect.UndeclaredThrowableException. Dynamic proxies commonly wrap an exception that the invoked interface method does not declare. The deciding reading is the deepest cause: com.lvcontrol.ignition.scripting.GatewayScripts cannot be cast to class java.io.Serializable. The stack places that failure in ModuleInvoke.invoke while the gateway builds the response.
Where Does the multiply Branch Stop?
The expression rpc.getScripts().multiply(val1, val2) is two Java calls, not one composite server operation. First, the RPC proxy invokes the remote getScripts(). Only after that call returns could Java invoke multiply on the returned reference. The gateway therefore tries to send the GatewayScripts instance to the client before multiplication begins.
A normal module RPC boundary transfers method arguments and return data. It does not create a transparent remote reference to an arbitrary gateway object. GatewayScripts also belongs to the gateway module class loader, while java.io.Serializable belongs to the bootstrap loader. Making an implementation class serializable would not turn it into a gateway-resident remote service; it would only attempt to copy its state, with additional class-availability and object-graph constraints.
Take the return-type reading at every root RPC method. Scalars and deliberately shared data-transfer values are appropriate candidates. Gateway contexts, service implementations, database resources, runtime managers, and handler instances stay behind the boundary. For this branch, the root handler must call GatewayScripts.multiply on the gateway and return the resulting double.
Can One Root Handler Expose Multiple Service APIs?
Yes. Separate the public RPC contract from the internal service layout. The hook supplies one root object for the module route shown here, but that object can implement multiple interfaces and delegate each method to the matching service. The classes that perform tag operations, script operations, or CRUD operations can remain independent.
| Design | RPC-visible methods | Strength | Primary constraint |
|---|---|---|---|
| Typed root delegate | Methods from interfaces such as IFoo and IBar
|
Compile-time contracts and straightforward tracing | The root contains delegation methods |
| Generic second-level dispatcher | A method such as invoke(Class<?>, String, Object...)
|
Reduces handwritten forwarding methods | Moves signature checking and method selection to runtime |
| Return a service object |
getScripts() or getTagHandler()
|
Appears concise in client code | Fails when the gateway implementation cannot cross the serialization boundary |
| Custom namespaces | Multiple named handlers within a module | Separates handler groups at the RPC routing layer | This capability was announced for 8.3; select it only after confirming it in the installed SDK |
The typed delegate pattern keeps the API explicit:
public class ModuleRPC implements IFoo, IBar {
IFoo fooImpl;
IBar barImpl;
@Override
public void foo() {
fooImpl.foo();
}
@Override
public void bar() {
barImpl.bar();
}
}
An IDE can generate these delegate methods from the implementation references. Kotlin language delegation can express the same arrangement as class ModuleRPC(context: GatewayContext) : IFoo by FooImpl(context), IBar by BarImpl(context). In either form, the services remain gateway-side and only method results cross the wire.
When Is a Generic Dispatcher the Better Branch?
A second-level dispatcher is useful when the service API is large enough that thousands of forwarding methods would dominate maintenance. Register each allowed service in a map and expose one root operation that accepts the service identifier, method name, and arguments:
@Override
public <T> T invoke(Class<?> apiClass, String method, Object... args) {
var service = serviceMap.get(apiClass);
Objects.requireNonNull(service, "No service found for class " + apiClass);
try {
return ReflectionUtils.invokeMethod(service, method, args);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
This preserves a single gateway RPC entry point while allowing internal service classes to own their implementations. Keep serviceMap as an allowlist. Never accept an arbitrary class name and reflect across the gateway class path. The API class must be available from the shared contract used on both sides, and every result still has to be transferable through RPC.
The shown reflection helper constructs parameter types with args[i].getClass() and calls beanClass.getMethod(methodName, argTypes). That exact lookup has three recurring traps. A null argument has no runtime class and causes a failure before lookup. A boxed runtime value does not directly name a primitive parameter type. Overloads declared against an interface or superclass may not exactly match the concrete argument class. Varargs and overloaded methods also make name-plus-runtime-class dispatch ambiguous.
If those cases exist, transmit or derive an unambiguous method signature and implement compatible-parameter matching instead of exact getClass() matching. Reject missing, inaccessible, and ambiguous methods explicitly. A typed root delegate avoids these runtime branches and remains the preferred choice when the forwarding surface is manageable.
Which Resolving Branch Should Be Implemented?
- Keep
GatewayHook.getRPCHandler(ClientReqSession session, String projectName)returning the module’s root RPC implementation. - Remove RPC methods whose purpose is to return
GatewayScripts,GatewayTagHandler, or another gateway-side service instance. - Add callable operations to the shared RPC contract. For the failing example, expose
multiply(double val1, double val2)on the root contract just assquare(double value)is exposed. - Store or construct the gateway implementation behind the root handler. In the root
multiplymethod, call the gateway-side script service and return its numeric result. - Change
ClientScripts.multiplyfromrpc.getScripts().multiply(val1, val2)to the direct root callrpc.multiply(val1, val2). - For a large API, either generate typed delegation methods or expose the allowlisted
invokedispatcher. Do not mix a generic dispatcher with unrestricted reflection. - Rebuild and deploy matching common, client, and gateway artifacts so the proxy interface and root implementation agree on names, parameter types, and return types.
The same rule applies to tag and CRUD services. A client method requests an operation; the root handler delegates that operation within the gateway; the gateway sends back data. A getter that returns an implementation class crosses the architectural boundary in the wrong direction.
How Is the Fix Verified End to End?
- Confirm the client or Designer shows an active gateway connection.
- Call
square(value)and record the returned value. A failure here points back to connection, module routing, handler construction, or deployment rather than the service split. - Call the corrected direct
multiply(val1, val2)route and compare the returned number with the expected multiplication result. - Inspect the gateway exception chain if the call fails. The fixed path must not contain a cast from
GatewayScriptstojava.io.Serializable. - Exercise one method from every delegated interface or mapped service. Include overload, primitive, null, and shared-data cases that the API actually permits.
- Submit an unknown service key or method to the generic dispatcher, if used, and verify that it is rejected at the allowlist or lookup boundary rather than invoking another gateway class.
What Do Engineers Ask About Multiple RPC Handlers?
How do I expose multiple RPC services from one Ignition module?
Return one root object from getRPCHandler, then have it implement multiple shared interfaces or expose an allowlisted second-level dispatcher. Keep each service implementation behind that root object.
How do I fix GatewayScripts cannot be cast to java.io.Serializable?
Stop returning GatewayScripts from an RPC method. Add the requested operation to the root RPC contract, execute it on the gateway-side service, and return only its result.
How do I diagnose an UndeclaredThrowableException from ModuleRPCFactory?
Read through every caused-by entry to the deepest gateway exception. In this failure, the decisive cause is the ClassCastException raised while serializing the getScripts() result.
How do I avoid writing thousands of RPC delegate methods?
Generate typed delegate methods in the IDE, use language-level delegation, or expose invoke(Class<?>, String, Object...) backed by a service allowlist. Account for nulls, primitives, inheritance, overloads, and ambiguous reflection matches.
How do I verify the corrected RPC path?
Run the working square control, call direct multiply, compare its numeric result with the expected product, and confirm the exception chain contains no attempted serialization of GatewayScripts.