Version: v1.4.1
Description
workflow.SignalWorkflow silently truncates large integer values (any int64/uint64/uint exceeding 2^53 ≈ 9.0e15) in the signal payload. The value arrives at the receiver rounded to the nearest float64-representable integer. No error is returned and no warning is logged — the corruption is invisible to the caller.
Root Cause
workflow.SignalWorkflow[T] is implemented by scheduling a system activity:
// workflow/signal.go
func SignalWorkflow[T any](ctx Context, instanceID string, name string, arg T) Future[any] {
var a *signals.Activities
return ExecuteActivity[any](ctx, ..., a.DeliverWorkflowSignal, instanceID, name, arg)
}
DeliverWorkflowSignal accepts arg as interface{}:
// internal/signals/activities.go
func (a *Activities) DeliverWorkflowSignal(ctx context.Context, instanceID, signalName string, arg interface{}) error {
return a.Signaler.SignalWorkflow(ctx, instanceID, signalName, arg)
}
When go-workflows serialises the activity arguments to JSON and then deserialises them back into the function's parameter types, the arg interface{} parameter causes the standard encoding/json behaviour: all JSON numbers are decoded into float64. Since float64 only has 53 bits of mantissa, any integer larger than 2^53 is rounded to the nearest representable value. The correctly-typed signal channel on the receiver side receives the already-corrupted value — there is no second chance for the type information to rescue the precision.
Reproduction
type signalTestPayload struct {
Id uint
}
func Test_SignalWorkflow_Uint_PrecisionLoss(t *testing.T) {
const (
receiverInstanceID = "receiver"
signalChannel = "test-signal"
)
testPayload := signalTestPayload{Id: 123456789012345678} // > 2^53
var received signalTestPayload
receiverWorkflow := func(ctx workflow.Context) error {
ch := workflow.NewSignalChannel[signalTestPayload](ctx, signalChannel)
payload, _ := ch.Receive(ctx)
received = payload
return nil
}
senderWorkflow := func(ctx workflow.Context) error {
sub := workflow.CreateSubWorkflowInstance[any](ctx, workflow.SubWorkflowOptions{
InstanceID: receiverInstanceID,
}, receiverWorkflow)
if _, err := workflow.SignalWorkflow[signalTestPayload](ctx, receiverInstanceID, signalChannel, testPayload).Get(ctx); err != nil {
return err
}
_, err := sub.Get(ctx)
return err
}
wt := tester.NewWorkflowTester[any](senderWorkflow, tester.WithTestTimeout(5*time.Second))
wt.Registry().RegisterWorkflow(receiverWorkflow)
wt.Execute(context.Background())
// FAILS:
// expected: 0x1b69b4ba630f34e (123456789012345678)
// actual: 0x1b69b4ba630f350 (123456789012345680)
require.Equal(t, testPayload.Id, received.Id)
}
Expected Behaviour
The value received on the signal channel equals the value that was sent, regardless of its magnitude.
Actual Behaviour
expected: 0x1b69b4ba630f34e (123456789012345678)
actual: 0x1b69b4ba630f350 (123456789012345680)
The value is silently rounded. The 2-unit difference is the standard float64 rounding for this number.
Suggested Fix
The arg interface{} parameter in DeliverWorkflowSignal should be replaced with a typed payload (e.g. json.RawMessage / payload.Payload) so that the concrete type information present at the call site of SignalWorkflow[T] is preserved through the round-trip. The type is known at serialisation time and should be serialised directly rather than being widened to interface{} before the JSON round-trip occurs.
An existing workaround is to bypass workflow.SignalWorkflow entirely and call client.SignalWorkflow directly from an activity — this calls the backend with the concrete type, so the JSON is deserialised into the correct target type from the start.
Version: v1.4.1
Description
workflow.SignalWorkflowsilently truncates large integer values (anyint64/uint64/uintexceeding 2^53 ≈ 9.0e15) in the signal payload. The value arrives at the receiver rounded to the nearest float64-representable integer. No error is returned and no warning is logged — the corruption is invisible to the caller.Root Cause
workflow.SignalWorkflow[T]is implemented by scheduling a system activity:DeliverWorkflowSignalacceptsargasinterface{}:When go-workflows serialises the activity arguments to JSON and then deserialises them back into the function's parameter types, the
arg interface{}parameter causes the standardencoding/jsonbehaviour: all JSON numbers are decoded intofloat64. Sincefloat64only has 53 bits of mantissa, any integer larger than 2^53 is rounded to the nearest representable value. The correctly-typed signal channel on the receiver side receives the already-corrupted value — there is no second chance for the type information to rescue the precision.Reproduction
Expected Behaviour
The value received on the signal channel equals the value that was sent, regardless of its magnitude.
Actual Behaviour
The value is silently rounded. The 2-unit difference is the standard float64 rounding for this number.
Suggested Fix
The
arg interface{}parameter inDeliverWorkflowSignalshould be replaced with a typed payload (e.g.json.RawMessage/payload.Payload) so that the concrete type information present at the call site ofSignalWorkflow[T]is preserved through the round-trip. The type is known at serialisation time and should be serialised directly rather than being widened tointerface{}before the JSON round-trip occurs.An existing workaround is to bypass
workflow.SignalWorkflowentirely and callclient.SignalWorkflowdirectly from an activity — this calls the backend with the concrete type, so the JSON is deserialised into the correct target type from the start.