From 3269bf13ba3859ed1204cf585192edb1ced211ef Mon Sep 17 00:00:00 2001 From: David Lechner Date: Mon, 7 Sep 2026 12:11:40 -0500 Subject: [PATCH] pbio, bricks: Add program exit code to status report. The status report event now also carries the exit code of the program that ran most recently. It is recorded just before clearing the user program running flag so that hosts always receive both in the same update. Stopping a program gives it a distinct exit code so that it can be told apart from a program that ran to completion. --- CHANGELOG.md | 5 ++ bricks/_common/micropython.c | 91 +++++++++++++++++++++++++------- bricks/_common/mpconfigport.h | 3 ++ lib/pbio/include/pbio/protocol.h | 41 ++++++++++++-- lib/pbio/include/pbsys/main.h | 8 ++- lib/pbio/include/pbsys/status.h | 1 + lib/pbio/src/protocol/pybricks.c | 5 +- lib/pbio/sys/main.c | 7 +-- lib/pbio/sys/status.c | 19 ++++++- 9 files changed, 148 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67011bc14..34b9c9033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## [Unreleased] +### Added +- Added the exit code of the most recently ended program to the status report + event. Stopping a program now also gives it a distinct exit code, so hosts + can tell it apart from a program that ran to completion. + ### Changed - Updated MicroPython to v1.29.0. - On the Move Hub, `bytes()` and `bytes.find()` now truncate out-of-range diff --git a/bricks/_common/micropython.c b/bricks/_common/micropython.c index dd01f63cc..1e55ec5a5 100644 --- a/bricks/_common/micropython.c +++ b/bricks/_common/micropython.c @@ -46,11 +46,18 @@ void pbsys_main_stop_program(bool force_stop) { if (force_stop) { mp_sched_vm_abort(); } else { + // The value of SystemExit is the exit code of the program, so give it + // the stop code. This lets the host tell a program that was stopped + // apart from one that ran to completion or called sys.exit(). + static const mp_rom_obj_tuple_t args = { + { &mp_type_tuple }, 1, { MP_ROM_INT(PBIO_PYBRICKS_EXIT_CODE_STOPPED) } + }; + static mp_obj_exception_t system_exit; system_exit.base.type = &mp_type_SystemExit; system_exit.traceback_alloc = system_exit.traceback_len = 0; system_exit.traceback_data = NULL; - system_exit.args = (mp_obj_tuple_t *)&mp_const_empty_tuple_obj; + system_exit.args = (mp_obj_tuple_t *)&args; mp_sched_exception(MP_OBJ_FROM_PTR(&system_exit)); } @@ -65,6 +72,40 @@ bool pbsys_main_stdin_event(uint8_t c) { return false; } +// The exit codes reported to the host are the same as the ones that pyexec +// returns for the REPL and for programs run with pyexec_frozen_module(). +_Static_assert(PYEXEC_NORMAL_EXIT == PBIO_PYBRICKS_EXIT_CODE_OK, "wrong ok code"); +_Static_assert(PYEXEC_UNHANDLED_EXCEPTION == PBIO_PYBRICKS_EXIT_CODE_EXCEPTION, "wrong exception code"); +_Static_assert(PYEXEC_KEYBOARD_INTERRUPT == PBIO_PYBRICKS_EXIT_CODE_INTERRUPTED, "wrong interrupt code"); +_Static_assert(PYEXEC_ABORT == PBIO_PYBRICKS_EXIT_CODE_ABORTED, "wrong abort code"); + +// Gets the pyexec-style return value for the exception that ended a program. +// The low byte is the exit code as it will be reported to the host. +static int pyexec_ret_from_exception(mp_obj_t exc) { + + if (mp_obj_exception_match(exc, MP_OBJ_FROM_PTR(&mp_type_SystemExit))) { + // As in CPython, no value or None means a normal exit, an integer is + // the exit code itself, and any other object is an error. A program + // stopped on request gets the stop code this way. + mp_obj_t value = mp_obj_exception_get_value(exc); + int exit_code = PBIO_PYBRICKS_EXIT_CODE_OK; + if (mp_obj_is_int(value)) { + exit_code = mp_obj_int_get_truncated(value); + } else if (value != mp_const_none) { + exit_code = PBIO_PYBRICKS_EXIT_CODE_EXCEPTION; + } + // The flag tells callers that the program exited instead of running to + // completion, which an exit code of 0 does not distinguish. + return exit_code | PYEXEC_FORCED_EXIT; + } + + if (mp_obj_exception_match(exc, MP_OBJ_FROM_PTR(&mp_type_KeyboardInterrupt))) { + return PBIO_PYBRICKS_EXIT_CODE_INTERRUPTED; + } + + return PBIO_PYBRICKS_EXIT_CODE_EXCEPTION; +} + // Prints the exception that ended the program. static void print_final_exception(mp_obj_t exc, int ret) { @@ -98,8 +139,8 @@ static void print_final_exception(mp_obj_t exc, int ret) { } #if PBSYS_CONFIG_FEATURE_BUILTIN_USER_PROGRAM_REPL -static void run_repl(void) { - int ret = 0; +static int run_repl(void) { + int ret = PBIO_PYBRICKS_EXIT_CODE_OK; readline_init0(); @@ -125,16 +166,19 @@ static void run_repl(void) { // if vm abort if (nlr.ret_val == NULL) { // we are shutting down, so don't bother with cleanup - return; + return PBIO_PYBRICKS_EXIT_CODE_ABORTED; } // clear any pending exceptions (and run any callbacks). mp_handle_pending(MP_HANDLE_PENDING_CALLBACKS_AND_CLEAR_EXCEPTIONS); + ret = pyexec_ret_from_exception(MP_OBJ_FROM_PTR(nlr.ret_val)); // Print which exception triggered this. print_final_exception(MP_OBJ_FROM_PTR(nlr.ret_val), ret); } nlr_set_abort(NULL); + + return ret; } #endif // PBSYS_CONFIG_FEATURE_BUILTIN_USER_PROGRAM_REPL @@ -227,8 +271,8 @@ static void execute_rom_mpy_in_context(mp_module_context_t *module_context, mpy_ /** * Runs the __main__ module from user RAM. */ -static void run_user_program(void) { - int ret = 0; +static int run_user_program(void) { + int ret = PBIO_PYBRICKS_EXIT_CODE_OK; nlr_buf_t nlr; nlr.ret_val = NULL; @@ -257,16 +301,13 @@ static void run_user_program(void) { // if vm abort if (nlr.ret_val == NULL) { // we are shutting down, so don't bother with cleanup - return; + return PBIO_PYBRICKS_EXIT_CODE_ABORTED; } // Clear any pending exceptions (and run any callbacks). mp_handle_pending(MP_HANDLE_PENDING_CALLBACKS_AND_CLEAR_EXCEPTIONS); - if (mp_obj_is_subclass_fast(MP_OBJ_FROM_PTR(((mp_obj_base_t *)nlr.ret_val)->type), MP_OBJ_FROM_PTR(&mp_type_SystemExit))) { - // at the moment, the value of SystemExit is unused - ret = PYEXEC_FORCED_EXIT; - } + ret = pyexec_ret_from_exception(MP_OBJ_FROM_PTR(nlr.ret_val)); print_final_exception(MP_OBJ_FROM_PTR(nlr.ret_val), ret); @@ -279,13 +320,15 @@ static void run_user_program(void) { // but not reset so the user can restart them in the REPL. pbio_main_soft_stop(); - // Enter REPL. - run_repl(); + // Enter REPL. Its exit code is now the exit code of the program. + ret = run_repl(); } #endif // PBSYS_CONFIG_FEATURE_BUILTIN_USER_PROGRAM_REPL } nlr_set_abort(NULL); + + return ret; } pbio_error_t pbsys_main_program_validate(pbsys_main_program_t *program) { @@ -348,7 +391,11 @@ const char *pbsys_main_get_application_version_hash(void) { } // Runs MicroPython with the given program data. -void pbsys_main_run_program(pbsys_main_program_t *program) { +uint8_t pbsys_main_run_program(pbsys_main_program_t *program) { + + // Return value of the program, which holds the ::pbio_pybricks_exit_code_t + // exit code in its low byte. + int ret = PBIO_PYBRICKS_EXIT_CODE_OK; #if PBDRV_CONFIG_STACK_EMBEDDED // Stack limit should be less than real stack size, so we have a chance @@ -384,14 +431,14 @@ void pbsys_main_run_program(pbsys_main_program_t *program) { case PBIO_PYBRICKS_USER_PROGRAM_ID_REPL: // Run REPL with everything auto-imported. pb_package_pybricks_init(true); - run_repl(); + ret = run_repl(); break; #endif #if PBSYS_CONFIG_FEATURE_BUILTIN_USER_PROGRAM_PORT_VIEW && MICROPY_MODULE_FROZEN case PBIO_PYBRICKS_USER_PROGRAM_ID_PORT_VIEW: pb_package_pybricks_init(false); - pyexec_frozen_module("_builtin_port_view.py", false); + ret = pyexec_frozen_module("_builtin_port_view.py", false); break; #endif @@ -404,15 +451,15 @@ void pbsys_main_run_program(pbsys_main_program_t *program) { #if PBSYS_CONFIG_FEATURE_BUILTIN_USER_PROGRAM_EV3_APPS case PBIO_PYBRICKS_USER_PROGRAM_ID_EV3_MOTOR_BUTTON_CONTROL: pb_package_pybricks_init(false); - pyexec_frozen_module("_ev3_motor_button_control.py", false); + ret = pyexec_frozen_module("_ev3_motor_button_control.py", false); break; case PBIO_PYBRICKS_USER_PROGRAM_ID_EV3_MOTOR_IR_CONTROL: pb_package_pybricks_init(false); - pyexec_frozen_module("_ev3_motor_ir_control.py", false); + ret = pyexec_frozen_module("_ev3_motor_ir_control.py", false); break; case PBIO_PYBRICKS_USER_PROGRAM_ID_EV3_PORT_VIEW: pb_package_pybricks_init(false); - pyexec_frozen_module("_ev3_port_view.py", false); + ret = pyexec_frozen_module("_ev3_port_view.py", false); break; #endif @@ -420,13 +467,17 @@ void pbsys_main_run_program(pbsys_main_program_t *program) { // Init Pybricks package without auto-import. pb_package_pybricks_init(false); // Run loaded user program (just slot 0 for now). - run_user_program(); + ret = run_user_program(); break; } // Ensure everything is written before the user application is considered // done, so that the host does not receive stdout after receiving stop. pb_stdout_flush_to_new_line(); + + // Only the exit code is of interest to the system, not the internal + // PYEXEC_FORCED_EXIT flag above it. + return ret & 0xff; } void pbsys_main_run_program_cleanup(void) { diff --git a/bricks/_common/mpconfigport.h b/bricks/_common/mpconfigport.h index 8b77e2826..801bdf9d9 100644 --- a/bricks/_common/mpconfigport.h +++ b/bricks/_common/mpconfigport.h @@ -110,6 +110,9 @@ #endif #define MICROPY_KBD_EXCEPTION (1) #define MICROPY_ENABLE_VM_ABORT (1) +// Makes pyexec return the value passed to sys.exit() so that it can be +// reported to the host as the program exit code. +#define MICROPY_PYEXEC_ENABLE_EXIT_CODE_HANDLING (1) #define MICROPY_ENABLE_SCHEDULER (0) #define MICROPY_PY_INSTANCE_ATTRS (1) diff --git a/lib/pbio/include/pbio/protocol.h b/lib/pbio/include/pbio/protocol.h index b11865a2b..e1cfe4eb0 100644 --- a/lib/pbio/include/pbio/protocol.h +++ b/lib/pbio/include/pbio/protocol.h @@ -259,10 +259,14 @@ typedef enum { * Status report event. * * The payload is one 32-bit little-endian unsigned integer containing - * ::pbio_pybricks_status_flags_t flags and a one byte program identifier - * representing the currently active program if it is running. + * ::pbio_pybricks_status_flags_t flags, a one byte program identifier + * representing the currently active program if it is running, a one byte + * identifier of the currently selected program slot, and a one byte exit + * code of the program that ran most recently. * - * @since Pybricks Profile v1.0.0. Program identifier added in Pybricks Profile v1.4.0. + * @since Pybricks Profile v1.0.0. Program identifier added in Pybricks + * Profile v1.4.0. Slot added in Pybricks Profile v1.5.0. Exit code added + * in Pybricks Profile v1.6.0. */ PBIO_PYBRICKS_EVENT_STATUS_REPORT = 0, @@ -404,10 +408,37 @@ typedef enum { */ #define PBIO_PYBRICKS_STATUS_FLAG(status) (1 << status) +/** + * Well-known exit codes of the program that ran most recently, as reported in + * the status report event. + * + * A program can also exit with a value of its own choosing, so hosts must be + * prepared to receive any value. The values here follow the POSIX convention + * of 128 plus the signal number for programs that did not exit of their own + * accord, so that they don't clash with common program exit codes. + * + * @since Pybricks Profile v1.6.0 + */ +typedef enum { + /** The program ran to completion or exited without giving a value. */ + PBIO_PYBRICKS_EXIT_CODE_OK = 0, + /** The program ended with an unhandled exception. */ + PBIO_PYBRICKS_EXIT_CODE_EXCEPTION = 1, + /** The program was interrupted by the user, such as with Ctrl-C. */ + PBIO_PYBRICKS_EXIT_CODE_INTERRUPTED = 128 + 2, + /** The program was aborted because the hub is shutting down. */ + PBIO_PYBRICKS_EXIT_CODE_ABORTED = 128 + 9, + /** + * The program was asked to stop, either with the stop button on the hub or + * with ::PBIO_PYBRICKS_COMMAND_STOP_USER_PROGRAM. + */ + PBIO_PYBRICKS_EXIT_CODE_STOPPED = 128 + 15, +} pbio_pybricks_exit_code_t; + /** Size of status report event message in bytes. */ -#define PBIO_PYBRICKS_EVENT_STATUS_REPORT_SIZE 7 +#define PBIO_PYBRICKS_EVENT_STATUS_REPORT_SIZE 8 -uint32_t pbio_pybricks_event_status_report(uint8_t *buf, uint32_t flags, pbio_pybricks_user_program_id_t program_id, uint8_t slot); +uint32_t pbio_pybricks_event_status_report(uint8_t *buf, uint32_t flags, pbio_pybricks_user_program_id_t program_id, uint8_t slot, uint8_t exit_code); /** * Application-specific feature flag supported by a hub. diff --git a/lib/pbio/include/pbsys/main.h b/lib/pbio/include/pbsys/main.h index 967b3fab9..065f1918e 100644 --- a/lib/pbio/include/pbsys/main.h +++ b/lib/pbio/include/pbsys/main.h @@ -111,8 +111,11 @@ pbio_error_t pbsys_main_program_validate(pbsys_main_program_t *program); * This should be provided by the application running on top of pbio. * * @param [in] program Program size and data + * @returns The exit code of the program. This is the value it exited + * with, if any, or one of the well-known + * ::pbio_pybricks_exit_code_t values. */ -void pbsys_main_run_program(pbsys_main_program_t *program); +uint8_t pbsys_main_run_program(pbsys_main_program_t *program); /** * Cleans up after running main application program, such as wiping application @@ -169,7 +172,8 @@ static inline pbio_error_t pbsys_main_program_validate(pbsys_main_program_t *pro return PBIO_ERROR_NOT_SUPPORTED; } -static inline void pbsys_main_run_program(pbsys_main_program_t *program) { +static inline uint8_t pbsys_main_run_program(pbsys_main_program_t *program) { + return 0; } static inline void pbsys_main_stop_program(bool force_stop) { diff --git a/lib/pbio/include/pbsys/status.h b/lib/pbio/include/pbsys/status.h index 2c6f7b155..0b035715a 100644 --- a/lib/pbio/include/pbsys/status.h +++ b/lib/pbio/include/pbsys/status.h @@ -18,6 +18,7 @@ void pbsys_status_set_program_id(pbio_pybricks_user_program_id_t program_id); void pbsys_status_update_emit(void); void pbsys_status_set(pbio_pybricks_status_flags_t status); void pbsys_status_clear(pbio_pybricks_status_flags_t status); +void pbsys_status_clear_program_running(uint8_t exit_code); bool pbsys_status_test(pbio_pybricks_status_flags_t status); bool pbsys_status_test_debounce(pbio_pybricks_status_flags_t status, bool state, uint32_t ms); uint32_t pbsys_status_get_flags(void); diff --git a/lib/pbio/src/protocol/pybricks.c b/lib/pbio/src/protocol/pybricks.c index 196fe6e10..baf036fb9 100644 --- a/lib/pbio/src/protocol/pybricks.c +++ b/lib/pbio/src/protocol/pybricks.c @@ -21,18 +21,21 @@ _Static_assert(NUM_PBIO_PYBRICKS_STATUS <= sizeof(uint32_t) * 8, * * Program ID parameter was added in Pybricks Profile v1.4.0. * Slot parameter was added in Pybricks Profile v1.5.0. + * Exit code parameter was added in Pybricks Profile v1.6.0. * * @param [in] buf The buffer to hold the binary data. * @param [in] flags The status flags. * @param [in] program_id Program identifier of currently running program. * @param [in] slot The currently selected program slot. + * @param [in] exit_code Exit code of the program that ran most recently. * @return The number of bytes written to @p buf. */ -uint32_t pbio_pybricks_event_status_report(uint8_t *buf, uint32_t flags, pbio_pybricks_user_program_id_t program_id, uint8_t slot) { +uint32_t pbio_pybricks_event_status_report(uint8_t *buf, uint32_t flags, pbio_pybricks_user_program_id_t program_id, uint8_t slot, uint8_t exit_code) { buf[0] = PBIO_PYBRICKS_EVENT_STATUS_REPORT; pbio_set_uint32_le(&buf[1], flags); buf[5] = program_id; buf[6] = slot; + buf[7] = exit_code; return PBIO_PYBRICKS_EVENT_STATUS_REPORT_SIZE; } diff --git a/lib/pbio/sys/main.c b/lib/pbio/sys/main.c index 11897feb9..a2cc7fdb2 100644 --- a/lib/pbio/sys/main.c +++ b/lib/pbio/sys/main.c @@ -111,13 +111,14 @@ void pbsys_main(void) { // Run the main application. pbio_main_start_application_resources(); - pbsys_main_run_program(&program); + uint8_t exit_code = pbsys_main_run_program(&program); // Stop motors, user animations, user bluetooth activity, etc. err = pbio_main_stop_application_resources(); - // Get system back in idle state. - pbsys_status_clear(PBIO_PYBRICKS_STATUS_USER_PROGRAM_RUNNING); + // Get system back in idle state. This also makes the exit code of the + // program that just ended available to connected hosts. + pbsys_status_clear_program_running(exit_code); pbsys_host_stdin_set_callback(NULL); pbsys_program_stop_set_buttons(PBSYS_CONFIG_HMI_STOP_BUTTON); program.start_request_type = PBSYS_MAIN_PROGRAM_START_REQUEST_TYPE_NONE; diff --git a/lib/pbio/sys/status.c b/lib/pbio/sys/status.c index 8c0551fdb..2b2d65cf9 100644 --- a/lib/pbio/sys/status.c +++ b/lib/pbio/sys/status.c @@ -27,6 +27,8 @@ static struct { pbio_pybricks_user_program_id_t program_id; /** Currently selected program slot */ pbio_pybricks_user_program_id_t slot; + /** Exit code of the program that ran most recently. */ + uint8_t program_exit_code; } pbsys_status; /** @@ -35,7 +37,7 @@ static struct { void pbsys_status_update_emit(void) { uint8_t buf[PBIO_PYBRICKS_EVENT_STATUS_REPORT_SIZE]; - pbio_pybricks_event_status_report(buf, pbsys_status.flags, pbsys_status.program_id, pbsys_status.slot); + pbio_pybricks_event_status_report(buf, pbsys_status.flags, pbsys_status.program_id, pbsys_status.slot, pbsys_status.program_exit_code); pbsys_host_schedule_status_update(buf); // Other processes may be awaiting status changes, so poll. @@ -145,6 +147,21 @@ void pbsys_status_clear(pbio_pybricks_status_flags_t status) { pbsys_status_update_flag(status, false); } +/** + * Clears the user program running status indication and records the exit code + * of the program that just ended. + * + * The exit code is set before the flag is cleared so that hosts receive both + * in the same status update, meaning that the exit code is always up to date + * by the time they see that the program is no longer running. + * + * @param [in] exit_code The exit code of the program that just ended. + */ +void pbsys_status_clear_program_running(uint8_t exit_code) { + pbsys_status.program_exit_code = exit_code; + pbsys_status_update_flag(PBIO_PYBRICKS_STATUS_USER_PROGRAM_RUNNING, false); +} + /** * Tests if status indication is set. * @param [in] status The status indication to to test.