Unittest failing fdcan_test on Nucleo-G474RE
The test: https://github.com/modm-io/modm/blob/389a9c352e9d20c1f8ee8a40d5276c2fd3df0557/test/modm/platform/fdcan/fdcan_test.cpp#L88-L100

<details>
<summary>
The output:

```
[...]
FAIL: fdcan_test:92 : 1801 == 18193␊
FAIL: fdcan_test:93 : 0 == 1␊
FAIL: fdcan_test:96 : 0 == 1␊
[...]
```

</summary>

```
>>> fdcan_test␊
FAIL: fdcan_test:62 : true == false␊
FAIL: fdcan_test:92 : 1801 == 18193␊
FAIL: fdcan_test:93 : 0 == 1␊
FAIL: fdcan_test:96 : 0 == 1␊
FAIL: fdcan_test:93 : 1 == 2␊
FAIL: fdcan_test:96 : 1 == 2␊
FAIL: fdcan_test:96 : 52 == 2␊
FAIL: fdcan_test:93 : 2 == 3␊
FAIL: fdcan_test:96 : 2 == 3␊
FAIL: fdcan_test:96 : 2 == 3␊
FAIL: fdcan_test:96 : 86 == 3␊
FAIL: fdcan_test:93 : 3 == 4␊
FAIL: fdcan_test:96 : 3 == 4␊
FAIL: fdcan_test:96 : 3 == 4␊
FAIL: fdcan_test:96 : 3 == 4␊
FAIL: fdcan_test:96 : 239 == 4␊
FAIL: fdcan_test:93 : 4 == 5␊
FAIL: fdcan_test:96 : 4 == 5␊
FAIL: fdcan_test:96 : 4 == 5␊
FAIL: fdcan_test:96 : 4 == 5␊
FAIL: fdcan_test:96 : 4 == 5␊
FAIL: fdcan_test:96 : 4 == 5␊
FAIL: fdcan_test:93 : 5 == 6␊
FAIL: fdcan_test:96 : 5 == 6␊
FAIL: fdcan_test:96 : 5 == 6␊
FAIL: fdcan_test:96 : 5 == 6␊
FAIL: fdcan_test:96 : 5 == 6␊
FAIL: fdcan_test:96 : 5 == 6␊
FAIL: fdcan_test:96 : 5 == 6␊
FAIL: fdcan_test:93 : 6 == 7␊
FAIL: fdcan_test:96 : 6 == 7␊
FAIL: fdcan_test:96 : 6 == 7␊
FAIL: fdcan_test:96 : 6 == 7␊
FAIL: fdcan_test:96 : 6 == 7␊
FAIL: fdcan_test:96 : 6 == 7␊
FAIL: fdcan_test:96 : 6 == 7␊
FAIL: fdcan_test:96 : 6 == 7␊
FAIL: fdcan_test:93 : 7 == 8␊
FAIL: fdcan_test:96 : 7 == 8␊
FAIL: fdcan_test:96 : 7 == 8␊
FAIL: fdcan_test:96 : 7 == 8␊
FAIL: fdcan_test:96 : 7 == 8␊
FAIL: fdcan_test:96 : 7 == 8␊
FAIL: fdcan_test:96 : 7 == 8␊
FAIL: fdcan_test:96 : 7 == 8␊
FAIL: fdcan_test:96 : 7 == 8␊
FAIL: fdcan_test:99 : true == false␊
FAIL: fdcan_test:100 : true == false␊
```

</details>
diff --git a/src/modm/platform/can/stm32-fdcan/can.cpp.in b/src/modm/platform/can/stm32-fdcan/can.cpp.in
index 1499d9836..5fe3c7930 100644
--- a/src/modm/platform/can/stm32-fdcan/can.cpp.in
+++ b/src/modm/platform/can/stm32-fdcan/can.cpp.in
@@ -135,7 +135,7 @@ sendMsg(const modm::can::Message& message)
 {
 	using namespace modm::platform;
 
-	if (!Fdcan{{ id }}::isReadyToSend()) {
+	if (isHardwareTxQueueFull()) {
 		return false;
 	}
 
@@ -215,20 +215,25 @@ modm::platform::Fdcan{{ id }}::initializeWithPrescaler(
 MODM_ISR({{ reg }}_IT0)
 {
 %% if options["buffer.tx"] > 0
-	if (txQueue.isNotEmpty()) {
-		sendMsg(txQueue.get());
-		txQueue.pop();
+	if ({{ reg }}->IR & FDCAN_IR_TC) {
+		{{ reg }}->IR = FDCAN_IR_TC;
+		if (txQueue.isNotEmpty()) {
+			const bool success = sendMsg(txQueue.get());
+			if (success) {
+				txQueue.pop();
+			}
+		}
 	}
 %% endif
 
-	const auto callback = modm::platform::Fdcan{{ id }}::getErrorInterruptCallback();
-	if (callback) {
-		const bool hasErrorInterrupt = ({{ reg }}->IR & (FDCAN_IR_BO | FDCAN_IR_EW | FDCAN_IR_EP));
-		if (hasErrorInterrupt) {
+	const bool hasErrorInterrupt = ({{ reg }}->IR & (FDCAN_IR_BO | FDCAN_IR_EW | FDCAN_IR_EP));
+	if (hasErrorInterrupt) {
+		const auto callback = modm::platform::Fdcan{{ id }}::getErrorInterruptCallback();
+		if (callback) {
 			callback();
 		}
+		{{ reg }}->IR = FDCAN_IR_BO | FDCAN_IR_EW | FDCAN_IR_EP;
 	}
-	{{ reg }}->IR = FDCAN_IR_TC | FDCAN_IR_BO | FDCAN_IR_EW | FDCAN_IR_EP;
 }
 
 
@@ -237,24 +242,30 @@ MODM_ISR({{ reg }}_IT0)
 MODM_ISR({{ reg }}_IT1)
 {
 %% if options["buffer.rx"] > 0
+	int_fast16_t msgRetrieveLimit = rxQueue.getMaxSize() - rxQueue.getSize();
+
 	RxMessage rxMessage;
 
-	if (rxFifo0HasMessage()) {
+	while (rxFifo0HasMessage() && (msgRetrieveLimit > 0)) {
 		readMsg(rxMessage.message, 0, &rxMessage.filter_id, &rxMessage.timestamp);
-		// acknowledge interrupt flag
-		{{ reg }}->IR = FDCAN_IR_RF0N;
-
-		modm_assert_continue_ignore(rxQueue.push(rxMessage), "fdcan.rx.buffer",
-			"CAN receive software buffer overflowed!", {{ id }});
+		rxQueue.push(rxMessage);
+		msgRetrieveLimit--;
 	}
 
-	if (rxFifo1HasMessage()) {
+	while (rxFifo1HasMessage() && (msgRetrieveLimit > 0)) {
 		readMsg(rxMessage.message, 1, &rxMessage.filter_id, &rxMessage.timestamp);
-		// acknowledge interrupt flag
-		{{ reg }}->IR = FDCAN_IR_RF1N;
+		rxQueue.push(rxMessage);
+		msgRetrieveLimit--;
+	}
 
-		modm_assert_continue_ignore(rxQueue.push(rxMessage), "fdcan.rx.buffer",
-			"CAN receive software buffer overflowed!", {{ id }});
+	if (rxQueue.isFull()){
+		modm_assert_continue_ignore(false, "fdcan.rx.buffer",
+			"CAN receive software buffer full, not reading new message(s)!");
+		// disable rx ISR until we read data from the rxQueue (IST for tx remains active)
+		// The interrupt remains unacknowledged, so it fires again after the read.
+		{{ reg }}->ILE &= ~FDCAN_ILE_EINT1;
+	} else {
+		{{ reg }}->IR = FDCAN_IR_RF0N | FDCAN_IR_RF1N;  // acknowledge interrupt flags
 	}
 %% endif
 }
@@ -369,6 +380,7 @@ modm::platform::Fdcan{{ id }}::getMessage(can::Message& message, uint8_t *filter
 			(*timestamp) = rxMessage.timestamp;
 		}
 		rxQueue.pop();
+		{{ reg }}->ILE = FDCAN_ILE_EINT1 | FDCAN_ILE_EINT0; // reenable ISR, in case it was disabled due to rxQueue full
 		return true;
 	}
 %% else
@@ -395,10 +407,27 @@ modm::platform::Fdcan{{ id }}::isReadyToSend()
 %% endif
 }
 
-
 bool
 modm::platform::Fdcan{{ id }}::sendMessage(const can::Message& message)
 {
+%% if options["buffer.tx"] > 0
+	/* Disable CAN interrupts to prevent race condition:
+	 * sendMsg() could be called concurrently from the TX interrupt and
+	 * simultaneously access the same TX buffer.
+	 * isHardwareTxQueueFull() must be checked while interrupts are off to
+	 * prevent a "time-of-check to time-of-use" bug. */
+	struct Lock
+	{
+		const uint32_t flags;
+		/* An edge case could occur where the RX interrupt gets disabled
+		 * in the RX handler when the queue is full between reading ILE and
+		 * writing ILE = 0. This will invoke the RX handler once
+		 * after sendMessage() but not cause any further issues. */
+		Lock() : flags({{ reg }}->ILE) { {{ reg }}->ILE = 0; }
+		~Lock() { {{ reg }}->ILE = flags; }
+	} lock;
+%% endif
+
 	if (isHardwareTxQueueFull()) {
 %% if options["buffer.tx"] > 0
 		if (txQueue.isFull()) {
diff --git a/src/modm/platform/can/stm32-fdcan/can.hpp.in b/src/modm/platform/can/stm32-fdcan/can.hpp.in
index 61d07f791..cece05245 100644
--- a/src/modm/platform/can/stm32-fdcan/can.hpp.in
+++ b/src/modm/platform/can/stm32-fdcan/can.hpp.in
@@ -14,6 +14,8 @@
 #ifndef MODM_STM32_FDCAN{{ id }}_HPP
 #define MODM_STM32_FDCAN{{ id }}_HPP
 
+#include <optional>
+
 #include <modm/architecture/interface/can.hpp>
 #include <modm/architecture/interface/can_filter.hpp>
 #include <modm/platform/gpio/connector.hpp>
diff --git a/test/modm/platform/fdcan/fdcan_test.cpp b/test/modm/platform/fdcan/fdcan_test.cpp
index 883a9b6a7..e3be2682c 100644
--- a/test/modm/platform/fdcan/fdcan_test.cpp
+++ b/test/modm/platform/fdcan/fdcan_test.cpp
@@ -17,19 +17,14 @@
 using namespace modm::platform;
 
 void
-FdcanTest::setUp()
+FdcanTest::testSendReceive()
 {
 	Fdcan1::initialize<Board::SystemClock, 500_kbps, 1_pct>(9, Fdcan1::Mode::TestInternalLoopback);
-
 	// receive all extended messages
 	Fdcan1::setExtendedFilter(0, Fdcan1::FilterConfig::Fifo1,
 			modm::can::ExtendedIdentifier(0),
 			modm::can::ExtendedMask(0));
-}
 
-void
-FdcanTest::testSendReceive()
-{
 	modm::can::Message message{0x12345678, 7};
 	constexpr std::string_view data = "\xDE\xAD\xBE\xEF\x12\x34\x56";
 	std::copy(std::begin(data), std::begin(data) + 7, message.data);
@@ -51,6 +46,12 @@ FdcanTest::testSendReceive()
 void
 FdcanTest::testFilters()
 {
+	Fdcan1::initialize<Board::SystemClock, 500_kbps, 1_pct>(9, Fdcan1::Mode::TestInternalLoopback);
+	// receive all extended messages
+	Fdcan1::setExtendedFilter(0, Fdcan1::FilterConfig::Fifo1,
+			modm::can::ExtendedIdentifier(0),
+			modm::can::ExtendedMask(0));
+
 	Fdcan1::setStandardFilter(27, Fdcan1::FilterConfig::Fifo0,
 			modm::can::StandardIdentifier(0x108),
 			modm::can::StandardMask(0x1F8));
@@ -72,11 +73,22 @@ FdcanTest::testFilters()
 void
 FdcanTest::testBuffers()
 {
+	Fdcan1::initialize<Board::SystemClock, 500_kbps, 1_pct>(9, Fdcan1::Mode::TestInternalLoopback);
+	// receive all extended messages
+	Fdcan1::setExtendedFilter(0, Fdcan1::FilterConfig::Fifo1,
+			modm::can::ExtendedIdentifier(0),
+			modm::can::ExtendedMask(0));
+
+	// send (RxBufferSize + 2) messages, exceeds internal peripheral queue size (3
+	// msgs) as well as the software queue size, but not both added together. So
+	// no message should get lost.
+	const uint_fast16_t numberOfMsgs = Fdcan1::RxBufferSize + 2;
+
 	modm::can::Message message{0x4711, 0};
-	// send 8 messages, exceeds internal peripheral queue size
-	for (uint_fast8_t i = 0; i <= 8; ++i) {
-		message.setLength(i);
-		for (uint_fast8_t dataIndex = 0; dataIndex < i; ++dataIndex) {
+	for (uint_fast16_t i = 0; i <= numberOfMsgs; ++i) {
+		uint_fast8_t length = i % 8;
+		message.setLength(length);
+		for (uint_fast8_t dataIndex = 0; dataIndex < length; ++dataIndex) {
 			message.data[dataIndex] = i;
 		}
 		Fdcan1::sendMessage(message);
@@ -86,13 +98,13 @@ FdcanTest::testBuffers()
 
 	// try to receive same messages
 	modm::can::Message receivedMessage;
-	for (uint_fast8_t i = 0; i <= 8; ++i) {
+	for (uint_fast16_t i = 0; i <= numberOfMsgs; ++i) {
 		TEST_ASSERT_TRUE(Fdcan1::getMessage(receivedMessage));
 
 		TEST_ASSERT_EQUALS(receivedMessage.getIdentifier(), 0x4711u);
-		TEST_ASSERT_EQUALS(receivedMessage.getLength(), i);
-
-		for (uint_fast8_t dataIndex = 0; dataIndex < i; ++dataIndex) {
+		uint_fast8_t length = i % 8;
+		TEST_ASSERT_EQUALS(receivedMessage.getLength(), length);
+		for (uint_fast8_t dataIndex = 0; dataIndex < length; ++dataIndex) {
 			TEST_ASSERT_EQUALS(receivedMessage.data[dataIndex], i);
 		}
 	}
diff --git a/test/modm/platform/fdcan/fdcan_test.hpp b/test/modm/platform/fdcan/fdcan_test.hpp
index b952af6e9..208e8c057 100644
--- a/test/modm/platform/fdcan/fdcan_test.hpp
+++ b/test/modm/platform/fdcan/fdcan_test.hpp
@@ -15,9 +15,6 @@
 class FdcanTest : public unittest::TestSuite
 {
 public:
-	void
-	setUp() override;
-
 	void
 	testSendReceive();
 
diff --git a/tools/scripts/docs_modm_io_generator.py b/tools/scripts/docs_modm_io_generator.py
index de3a7ff7e..684014a72 100755
--- a/tools/scripts/docs_modm_io_generator.py
+++ b/tools/scripts/docs_modm_io_generator.py
@@ -19,11 +19,13 @@ import tempfile
 import argparse
 import datetime
 import platform
-import multiprocessing as mp
+import subprocess
+from multiprocessing.pool import ThreadPool
 from pathlib import Path
 from jinja2 import Environment, FileSystemLoader
 from collections import defaultdict
 
+is_running_on_macos = "macOS" in platform.platform()
 def repopath(path):
     return Path(__file__).absolute().parents[2] / path
 def relpath(path):
@@ -101,26 +103,32 @@ def main():
     test_group.add_argument("--test2", "-t2", action='store_true', help="Test mode: generate only a few targets. List has targets from the real target list.")
     parser.add_argument("--jobs", "-j", type=int, default=2, help="Number of parallel doxygen processes")
     parser.add_argument("--local-temp", "-l", action='store_true', help="Create temporary directory inside current working directory")
-    group = parser.add_mutually_exclusive_group(required=True)
+    group = parser.add_mutually_exclusive_group()
     group.add_argument("--compress", "-c", action='store_true', help="Compress output into gzip archive")
     group.add_argument("--output", "-o", type=str, help="Output directory")
     parser.add_argument("--overwrite", "-f", action='store_true', help="Overwrite existing data in output directory (Removes all files from output directory.)")
     parser.add_argument("--deduplicate", "-d", action='store_true', help="Deduplicate identical files with symlinks.")
+    parser.add_argument("--target-job", help="Create a single target from job string.")
     args = parser.parse_args()
 
+    # Called by the thread pool below as a workaround for buggy multiprocessing
+    if args.target_job:
+        exit(0 if create_target(args.target_job) else -1)
+
     device_list, board_list, family_list = get_targets()
     if args.test:
         device_list = ["stm32f103c8t6", "stm32f417zgt6"] + family_list
-        device_list = list(set(device_list))
         board_list = [("arduino-nano", "atmega328p-au"), ("arduino-uno", "atmega328p-au"), ("nucleo-g474re", "stm32g474ret6"),
                       ("blue-pill", "stm32f103c8t6"), ("feather-m0", "samd21g18a-uu"), ("disco-f469ni", "stm32f469nih6")]
     elif args.test2:
         device_list = ["hosted-linux", "atmega328p-pu", "stm32f103zgt7"]
         board_list = [("nucleo-g474re", "stm32g474ret6")]
+    device_list = list(set(device_list))
 
     final_output_dir = Path(args.output).absolute() if args.output else "."
     template_path = os.path.realpath(os.path.dirname(sys.argv[0]))
     cwd = Path().cwd()
+    filepath = os.path.abspath(__file__)
     if args.local_temp:
         temp_dir = str(cwd)
     else:
@@ -136,16 +144,16 @@ def main():
         print("Starting to generate documentation...")
         template_overview(output_dir, device_list, board_list, template_path)
         print("... for {} devices, estimated memory footprint is {} MB".format(len(device_list) + len(board_list), (len(device_list)*70)+2000))
-        with mp.get_context("spawn").Pool(args.jobs) as pool:
+        with ThreadPool(args.jobs) as pool:
             # We can only pass one argument to pool.map
-            devices = ["{}|{}|{}||{}".format(modm_path, tempdir, dev, args.deduplicate) for dev in device_list]
-            devices += ["{}|{}|{}|{}|{}".format(modm_path, tempdir, dev, brd, args.deduplicate) for (brd, dev) in board_list]
-            results = pool.map(create_target, devices)
+            devices = [f"python3 {filepath} --target-job '{modm_path}|{tempdir}|{dev}||{args.deduplicate}'" for dev in device_list]
+            devices += [f"python3 {filepath} --target-job '{modm_path}|{tempdir}|{dev}|{brd}|{args.deduplicate}'" for (brd, dev) in board_list]
+            results = pool.map(lambda d: subprocess.run(d, shell=True).returncode, list(set(devices)))
         # output_dir.rename(cwd / 'modm-api-docs')
         if args.compress:
             print("Zipping docs ...")
             # Zipping may take more than 10 minutes
-            os.system("(cd {} && tar -czvf {} .)".format(str(output_dir), str(cwd / 'modm-api-docs.tar.gz')))
+            os.system(f"(cd {str(output_dir)} && {'g' if is_running_on_macos else ''}tar --checkpoint=.100 -czf {str(cwd / 'modm-api-docs.tar.gz')} .)")
             # shutil.make_archive(str(cwd / 'modm-api-docs'), 'gztar', str(output_dir))
         else:
             if args.overwrite and final_output_dir.exists():
@@ -157,8 +165,9 @@ def main():
                         os.remove(i)
             print('Moving {} -> {}'.format(output_dir, final_output_dir))
             #shutil.move(str(output_dir) + '/', str(final_output_dir))
+            print(f"Moving {output_dir} -> {final_output_dir}")
             output_dir.rename(final_output_dir)
-        return results.count(False)
+        return results.count(0) == len(results)
 
 
 def create_target(argument):
@@ -209,6 +218,7 @@ def create_target(argument):
                 key = file.relative_to(tempdir).parts[4:]
                 if key:
                     symlinks[os.path.join(*key)].append(file)
+            dot_counter = 0
             for file in srcdir.rglob('*'):
                 if file.is_dir():
                     print(end="", flush=True)
@@ -220,7 +230,8 @@ def create_target(argument):
                     del symlinks[key]
                 fhash = hash(file.read_bytes())
                 if fhash in symlinks:
-                    print(".", end="")
+                    dot_counter += 1
+                    if dot_counter % 30 == 0: print(".", end="")
                     rpath = symlinks[fhash][0].relative_to(tempdir / 'output/develop/api')
                     lpath = os.path.relpath(srcdir, file.parent)
                     sympath = os.path.join("..", lpath, rpath)
@@ -229,8 +240,8 @@ def create_target(argument):
                     file.symlink_to(sympath)
 
         # Only move folder *after* deduplication to prevent race condition with file.unlink()
+        print(f"\nMoving {srcdir.relative_to(tempdir)} -> {destdir.relative_to(tempdir)}", flush=True)
         srcdir.rename(destdir)
-        print(end="", flush=True)
         return True
     except Exception as e:
         print("Error generating documentation for device {}: {}".format(output_dir, e))
@@ -258,4 +269,4 @@ def template_overview(output_dir, device_list, board_list, template_path):
 
 
 if __name__ == "__main__":
-    exit(main())
+    exit(0 if main() else -1)
['tools/scripts/docs_modm_io_generator.py', 'src/modm/platform/can/stm32-fdcan/can.hpp.in', 'test/modm/platform/fdcan/fdcan_test.hpp', 'test/modm/platform/fdcan/fdcan_test.cpp', 'src/modm/platform/can/stm32-fdcan/can.cpp.in']