From 3e8bf93a7a153af4c1c5ea247f88ffe03f6a71ca Mon Sep 17 00:00:00 2001 From: Zimri Leisher Date: Wed, 9 Sep 2026 16:06:43 -0400 Subject: [PATCH] Reject a constant write_to_port port index at or above MAX_SERIAL_PORTS Co-Authored-By: Claude Opus 5 (1M context) --- src/fpy/macros.py | 28 +++++++++++++++---------- src/fpy/semantics.py | 31 +++++++++++++++++++++++++++- test/fpy/test_write_to_port.py | 37 ++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/src/fpy/macros.py b/src/fpy/macros.py index a2acd6d..7264c9c 100644 --- a/src/fpy/macros.py +++ b/src/fpy/macros.py @@ -358,6 +358,22 @@ def generate_write_to_port_llvm(builder, args): return None +WRITE_TO_PORT_MACRO = BuiltinFuncSymbol( + "write_to_port", + NOTHING, + [ + ("port", SerialPortIndex, None), + ("value", SIZED, None), + ], + generate_write_to_port, + generate_write_to_port_llvm, + const_arg_indices=frozenset({0}), # port must be compile-time constant +) + +# The sentinel constant Svc.Fpy.SerialPortIndex declares one past its last real +# port; the sequencer bounds-checks every port index against it. +MAX_SERIAL_PORTS_NAME = "MAX_SERIAL_PORTS" + TIME_MACRO = BuiltinFuncSymbol( "time", TIME, @@ -430,15 +446,5 @@ def generate_write_to_port_llvm(builder, args): const_arg_indices=frozenset({0, 1}), ), # Serial write: port typed by the dictionary-backed Svc.Fpy.SerialPortIndex enum; value typed SIZED - "write_to_port": BuiltinFuncSymbol( - "write_to_port", - NOTHING, - [ - ("port", SerialPortIndex, None), - ("value", SIZED, None), - ], - generate_write_to_port, - generate_write_to_port_llvm, - const_arg_indices=frozenset({0}), # port must be compile-time constant - ), + "write_to_port": WRITE_TO_PORT_MACRO, } diff --git a/src/fpy/semantics.py b/src/fpy/semantics.py index b25a2a8..c18fd19 100644 --- a/src/fpy/semantics.py +++ b/src/fpy/semantics.py @@ -10,7 +10,7 @@ from typing import Union from fpy.error import CompileError, diagnostic_context -from fpy.macros import TIME_MACRO +from fpy.macros import MAX_SERIAL_PORTS_NAME, TIME_MACRO, WRITE_TO_PORT_MACRO from fpy.types import ( pick_binary_op_case, pick_unary_op_case, @@ -2246,6 +2246,31 @@ def _round_float_to_type(value: float, to_type: FpyType) -> float | None: return struct.unpack(fmt, packed)[0] + @staticmethod + def _check_serial_port_in_range( + port: FpyValue, node: Ast, state: CompileState + ) -> bool: + """Check that a constant serial port index names a port the sequencer + will accept: at or above the MAX_SERIAL_PORTS sentinel (the sentinel + itself included) it always fails at run time with + SERIAL_PORT_INVALID_INDEX. Reports an error and returns False if not. + + A dictionary whose enum declares no sentinel gives nothing to check + against, so the index is left to the run-time check.""" + max_ports = port.type.enum_dict.get(MAX_SERIAL_PORTS_NAME) + if max_ports is None: + return True + index = port.type.enum_dict[port.val] + if 0 <= index < max_ports: + return True + state.err( + f"Serial port {port.type.display_name}.{port.val} has index {index}, " + f"which is outside the {max_ports} ports the sequencer has " + f"({port.type.display_name}.{MAX_SERIAL_PORTS_NAME} is {max_ports})", + node, + ) + return False + @staticmethod def _parse_time_string( time_str: str, time_base: str, time_context: int, node: Ast, state: CompileState @@ -2618,6 +2643,10 @@ def visit_AstFuncCall(self, node: AstFuncCall, state: CompileState): ) ) return + if func is WRITE_TO_PORT_MACRO and not self._check_serial_port_in_range( + arg_values[0], resolved_args[0], state + ): + return if unknown_value: # we will have to calculate this at runtime diff --git a/test/fpy/test_write_to_port.py b/test/fpy/test_write_to_port.py index 45672b9..05302d2 100644 --- a/test/fpy/test_write_to_port.py +++ b/test/fpy/test_write_to_port.py @@ -150,6 +150,43 @@ def test_max_port_index(self, fprime_test_api): assert len(dirs) == 1 assert dirs[0].portIndex == 4 + def test_max_serial_ports_sentinel_rejected(self, fprime_test_api): + # MAX_SERIAL_PORTS is the sentinel one past the last real port, so the + # sequencer always rejects it at run time; reject it at compile time + seq = """ +value: U32 = 42 +write_to_port(Svc.Fpy.SerialPortIndex.MAX_SERIAL_PORTS, value) +""" + assert_compile_failure(fprime_test_api, seq, match="outside the 5 ports") + + def test_port_at_or_above_sentinel_rejected(self, fprime_test_api, tmp_path): + # A port constant is out of range whenever its index reaches the + # sentinel, not just when it is the sentinel: here MAX_SERIAL_PORTS is + # lowered to 2, which puts EXAMPLE_PORT_4 out of range. + d = json.loads(Path(default_dictionary).read_text()) + for t in d["typeDefinitions"]: + if t.get("qualifiedName") == "Svc.Fpy.SerialPortIndex": + for c in t["enumeratedConstants"]: + if c["name"] == "MAX_SERIAL_PORTS": + c["value"] = 2 + custom_dict = tmp_path / "TwoPorts.json" + custom_dict.write_text(json.dumps(d)) + + seq = """ +value: U32 = 42 +write_to_port(Svc.Fpy.SerialPortIndex.EXAMPLE_PORT_4, value) +""" + try: + fpy.error.file_name = "" + fpy.error.input_text = seq + fpy.error.input_lines = seq.splitlines() + _build_global_scopes.cache_clear() + state = get_base_compile_state(str(custom_dict)) + with pytest.raises(fpy.error.CompileError, match="outside the 2 ports"): + analyze_ast(text_to_ast(seq), state) + finally: + _build_global_scopes.cache_clear() + def test_non_constant_port_rejected(self, fprime_test_api): # the port index must be a compile-time constant; an enum-typed variable # has the right type but is not const, so it is rejected