Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ add_executable(plc_main
${CMAKE_SOURCE_DIR}/core/src/plc_app/debug_write_journal.cpp
${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_io_cycle.cpp
${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_state_manager.cpp
${CMAKE_SOURCE_DIR}/core/src/plc_app/plc_switch.c
${CMAKE_SOURCE_DIR}/core/src/plc_app/plcapp_manager.c
${CMAKE_SOURCE_DIR}/core/src/plc_app/scan_cycle_manager.c
${CMAKE_SOURCE_DIR}/core/src/drivers/plugin_driver.c
Expand Down
107 changes: 98 additions & 9 deletions core/src/drivers/plugin_driver.c
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include "../plc_app/image_tables.h"
#include "../plc_app/journal_buffer.h"
#include "../plc_app/plc_state_manager.h"
#include "../plc_app/plc_switch.h"
#include "../plc_app/unix_socket.h"
#include "../plc_app/utils/log.h"
#include "../plc_app/utils/utils.h"
Expand Down Expand Up @@ -158,21 +159,71 @@ static uint8_t plugin_debug_write(uint8_t arr, uint16_t elem,
// plugin's own stop_loop is invoked. Plugins that enter fault-stopped state
// are expected to short-circuit their I/O during that window.
//
// No pre-check on plc_get_state() here: plc_begin_transition does the
// check atomically (under the same gate that prevents concurrent
// transitions), so doing it again outside would just re-introduce the
// check-then-act race the gate is there to close.
// No pre-check on plc_get_state() here: plc_claim_transition does the check
// atomically under the state lock (the state being TRANSITIONING is itself what
// prevents concurrent transitions), so doing it again outside would just
// re-introduce the check-then-act race that interlock exists to close.
static void plugin_request_plc_stop(const char *reason)
{
log_error("[PLUGIN] stop requested: %s", reason ? reason : "(no reason given)");
log_info("[PLUGIN] stop requested: %s", reason ? reason : "(no reason given)");
if (!plc_begin_transition(PLC_STATE_STOPPED))
{
// Either the PLC is already stopping/stopped or another stop
// is already in flight — either way, nothing to do.
//
// A stop dropped because another transition was in flight is recovered
// by the switch-movement reconciliation once that transition lands (see
// transition_worker in unix_socket.c), so a switch-driven stop cannot be
// lost here.
log_warn("[PLUGIN] stop request collapsed (already transitioning or not running)");
}
}

// Mirror of plugin_request_plc_stop, routed through the same transition path
// the socket START command uses. Gated on the mode switch: hardware is
// authoritative no matter who asks, so a plugin cannot start a PLC whose switch
// reads STOP (which also keeps a buggy plugin from defeating the interlock).
static void plugin_request_plc_start(const char *reason)
{
if (!plc_switch_allows_run())
{
log_warn("[PLUGIN] start request refused — mode switch is in STOP (%s)",
reason ? reason : "(no reason given)");
return;
}

log_info("[PLUGIN] start requested: %s", reason ? reason : "(no reason given)");
if (!plc_begin_transition(PLC_STATE_RUNNING))
{
log_warn("[PLUGIN] start request collapsed (already transitioning or already running)");
}
}

// Thin adapter so plugin_types.h can declare the position as a plain int and
// stay free of the plc_app include tree.
static void plugin_set_switch_position(int position)
{
plc_set_switch_position(position == PLC_SWITCH_STOP ? PLC_SWITCH_STOP : PLC_SWITCH_RUN);
}

// Map the runtime's PLCState onto the values FC 0x49 reports on baremetal
// targets (0 = STOPPED, 1 = RUNNING, 2 = ERROR) so vendor code driving a
// status LED can share one mapping across both target types. INIT and EMPTY
// are v4-only and have no physical meaning for an indicator, so they report as
// STOPPED — the PLC is not executing.
static int plugin_get_plc_state(void)
{
switch (plc_get_state())
{
case PLC_STATE_RUNNING:
return 1;
case PLC_STATE_ERROR:
return 2;
default:
return 0;
}
}


// Python capsule destructor for runtime args
// Breakpoint here to debug capsule issues
Expand Down Expand Up @@ -687,6 +738,21 @@ int plugin_driver_cleanup_init(plugin_driver_t *driver)
return cleaned;
}

void plugin_driver_release_gil(void)
{
if (!Py_IsInitialized())
{
return;
}
/* Idempotent: a second call with the GIL already released would be calling
* PyEval_SaveThread() without holding it. */
if (main_tstate != NULL)
{
return;
}
main_tstate = PyEval_SaveThread();
}

// Call the thread function for each plugin
int plugin_driver_start(plugin_driver_t *driver)
{
Expand All @@ -701,11 +767,19 @@ int plugin_driver_start(plugin_driver_t *driver)
return 0;
}

// Only manage Python GIL if we have Python plugins and Python is initialized
// Only manage Python GIL if we have Python plugins and Python is initialized.
//
// Acquire-then-save leaves this thread without the GIL, which is the point:
// the plugin threads started below need it. The saved state is deliberately
// NOT stored in main_tstate -- this runs on the PLC cycle thread, and
// main_tstate is what plugin_driver_destroy restores before Py_FinalizeEx(),
// which must be the MAIN thread's state. Overwriting it here meant a shutdown
// after a start restored a state belonging to a thread that no longer exists.
// plugin_driver_release_gil() owns that value.
if (has_python_plugin && Py_IsInitialized())
{
gstate = PyGILState_Ensure();
main_tstate = PyEval_SaveThread();
gstate = PyGILState_Ensure();
PyEval_SaveThread();
}

for (int i = 0; i < driver->plugin_count; i++)
Expand Down Expand Up @@ -920,10 +994,18 @@ void plugin_driver_destroy(plugin_driver_t *driver)

if (python_initialized)
{
PyGILState_Release(local_gstate);
/* Py_FinalizeEx() requires the GIL, and getting there with it released is
* what used to segfault the runtime on every graceful shutdown where the
* PLC had never run (Py_FinalizeEx -> PyImport_GetModule with no thread
* state). main_tstate is only non-NULL once the GIL has been saved by the
* main thread, so when it is NULL the right move is to KEEP the state
* PyGILState_Ensure() gave us above rather than dropping it. */
if (main_tstate != NULL)
{
PyGILState_Release(local_gstate);
PyEval_RestoreThread(main_tstate);
/* Consumed: a second destroy must not restore a stale state. */
main_tstate = NULL;
}
Py_FinalizeEx();
}
Expand Down Expand Up @@ -1025,6 +1107,13 @@ void *generate_structured_args_with_driver(plugin_type_t type, plugin_driver_t *
// Plugin-initiated async PLC stop (for unrecoverable hardware faults).
args->request_plc_stop = plugin_request_plc_stop;

// Run/stop control: the same transition path the socket START / STOP
// commands drive, plus the mode-switch store the runtime gates starts on
// and the state read a plugin uses to drive a status LED.
args->request_plc_start = plugin_request_plc_start;
args->set_switch_position = plugin_set_switch_position;
args->get_plc_state = plugin_get_plc_state;

// PLC base tick time. Runtime owns base_tick_ns; on first plugin init
// (before symbols_init) it carries the 20 ms default, so plugins must
// guard against the value being smaller than their needed resolution.
Expand Down
13 changes: 13 additions & 0 deletions core/src/drivers/plugin_driver.h
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ int plugin_driver_start(plugin_driver_t *driver);
int plugin_driver_stop(plugin_driver_t *driver);
void plugin_driver_destroy(plugin_driver_t *driver);

/* Release the Python GIL held by the calling thread after plugin loading, and
* remember the thread state so plugin_driver_destroy() can restore it before
* Py_FinalizeEx().
*
* Call from the MAIN thread, once, after plugins are loaded. The runtime used to
* call PyEval_SaveThread() directly and drop the returned state on the floor,
* which left the driver with nothing to restore: shutdown then finalised the
* interpreter with no GIL held and segfaulted -- on every graceful shutdown of a
* runtime whose PLC had never started, safe mode included. Keeping the
* bookkeeping next to the code that consumes it is what makes that
* unrepresentable. No-op when Python was never initialised. */
void plugin_driver_release_gil(void);

// Cycle hook functions for native plugins (called during PLC scan cycle)
// These iterate through all active native plugins and call their cycle hooks
// Plugins opt-in by implementing cycle_start/cycle_end; opt-out by not implementing them
Expand Down
90 changes: 90 additions & 0 deletions core/src/drivers/plugin_types.h
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,77 @@ typedef uint8_t (*plugin_debug_write_func_t)(uint8_t arr, uint16_t elem,
*/
typedef void (*plugin_request_plc_stop_func_t)(const char *reason);

/**
* @brief PLC start request from a plugin
*
* Mirror of plugin_request_plc_stop: both are thin wrappers over the same
* transition path the socket START / STOP commands drive, so a plugin-initiated
* run/stop is byte-for-byte the flow the editor already exercises.
*
* Refused while the stored mode-switch position is STOP (see
* plugin_set_switch_position_func_t) -- hardware is authoritative regardless of
* who asks. Non-blocking; `reason` is logged. Safe to call from any plugin
* thread, including while the PLC is stopped: the runtime keeps every plugin
* mapped across a stop, so this is how a hardware STOP -> RUN edge starts the
* PLC again.
*/
typedef void (*plugin_request_plc_start_func_t)(const char *reason);

/**
* @brief Report the run/stop mode-switch position
*
* THIS is the hardware interface on Linux. The runtime never reads hardware and
* never polls: the package decides how and how often it samples (GPIO
* interrupt, sysfs poll, fieldbus callback, its own thread) and calls this
* whenever the position changes.
*
* Stores only -- it starts nothing and stops nothing. On a switch change the
* plugin calls this and THEN asks for the matching transition:
*
* args->set_switch_position(PLC_SWITCH_STOP);
* args->request_plc_stop("mode switch moved to STOP");
*
* Always store the position BEFORE requesting the transition. On a falling edge
* that closes the window where a start could slip in; on a rising edge it stops
* the request being refused by the guard still reading the stale STOP.
*
* `position` is a plc_switch_t (0 = STOP, 1 = RUN). Declared as int here so
* this header stays free of the plc_app include tree. Idempotent, and safe to
* call from any plugin thread.
*
* Sample the switch from a thread started in init() and torn down in cleanup(),
* NOT in start_loop() / stop_loop(): the runtime calls stop_loop when the PLC
* stops, which is precisely when the position matters most. Make that start
* idempotent, because init() is re-entered on each PLC start. Reporting the
* initial position from init() also gates the boot auto-start, so a device
* powered up with its switch in STOP never starts and then bounces.
*/
typedef void (*plugin_set_switch_position_func_t)(int position);

/**
* @brief Read the current PLC state
*
* Returns 0 = STOPPED, 1 = RUNNING, 2 = ERROR (matching the values FC 0x49
* reports on baremetal targets, so vendor code can share one mapping).
*
* There is no state-change callback: a plugin driving a panel LED polls this.
* A device with no LED never calls it and the runtime is unaware.
*
* Poll it from the plugin's OWN thread -- the same sampler that reads the mode
* switch is the natural home. Do NOT poll it from cycle_end: cycle_end fires
* once per scan, so it stops being called the moment the PLC stops, and an LED
* driven from there would freeze showing RUNNING exactly when it needs to show
* STOPPED.
*
* IMPORTANT (applies to every field of plugin_runtime_args_t): the runtime
* frees the args pointer as soon as init() returns. Copy the struct BY VALUE
* during init -- `memcpy(&my_args, args, sizeof(plugin_runtime_args_t))` --
* and call through the copy. Caching the pointer is a use-after-free; calling
* a function pointer read back out of the freed struct crashes. See the
* s7comm plugin for the established pattern.
*/
typedef int (*plugin_get_plc_state_func_t)(void);

/**
* @brief Runtime buffer access structure for plugins
*
Expand Down Expand Up @@ -182,6 +253,25 @@ typedef struct
* Populated when the runtime initializes the plugin; may be 0 if
* symbols are not yet resolved (plugin must guard against zero). */
unsigned long long base_tick_ns;

/* ---------------------------------------------------------------------
* Run/stop control. Appended at the end of the struct so plugin binaries
* compiled against an earlier layout keep their field offsets.
*
* A plugin that ignores all three behaves exactly as before: the switch
* position stays at its RUN default, so every start path is unguarded.
* ------------------------------------------------------------------- */

/* Async request to run — see plugin_request_plc_start_func_t. */
plugin_request_plc_start_func_t request_plc_start;

/* Report the mode-switch position — see
* plugin_set_switch_position_func_t. The hardware interface on Linux. */
plugin_set_switch_position_func_t set_switch_position;

/* Current PLC state, for driving a status LED — see
* plugin_get_plc_state_func_t. */
plugin_get_plc_state_func_t get_plc_state;
} plugin_runtime_args_t;

#endif /* PLUGIN_TYPES_H */
16 changes: 16 additions & 0 deletions core/src/drivers/plugins/python/shared/plugin_runtime_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,24 @@ class PluginRuntimeArgs(ctypes.Structure):
("journal_write_int", ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int)),
("journal_write_dint", ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_uint)),
("journal_write_lint", ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_ulonglong)),
# Async request to stop the whole PLC: void (*)(const char *reason).
#
# This entry was missing while the C struct had the field, so every
# field after it was shifted by one pointer -- `base_tick_ns` was
# actually reading the request_plc_stop pointer. Keep this list in
# lockstep with plugin_types.h; the offsets are load-bearing.
("request_plc_stop", ctypes.CFUNCTYPE(None, ctypes.c_char_p)),
# PLC base tick time in nanoseconds (mirrors C-side base_tick_ns).
("base_tick_ns", ctypes.c_ulonglong),
# --- Run/stop control (appended in C, so appended here too) ---------
# Async request to run: void (*)(const char *reason). Refused while the
# mode switch reads STOP.
("request_plc_start", ctypes.CFUNCTYPE(None, ctypes.c_char_p)),
# Report the mode-switch position: void (*)(int position),
# 0 = STOP, 1 = RUN. Store the position BEFORE requesting a transition.
("set_switch_position", ctypes.CFUNCTYPE(None, ctypes.c_int)),
# Current PLC state: int (*)(void), 0 = STOPPED, 1 = RUNNING, 2 = ERROR.
("get_plc_state", ctypes.CFUNCTYPE(ctypes.c_int)),
]

def validate_pointers(self):
Expand Down
Loading