there is a problem with this code, whenever using allocate() and when the free space is on the end of the memory (rightmost, at the tail of the linked list memory), the execution freezes or runs indefinitely. Whats the exact bug here? #@title Main logic and classes (Process, Node, MemSimPage, MemorySimulation)

from re import split
from os import access
from typing import List, Callable
from enum import Enum
import matplotlib.pyplot as plt
import numpy as np
import time

class Process:
    def __init__(self, symbol, size, duration):
        self.symbol = symbol
        self.size = size
        self.remaining_time = duration

    def tick(self):
        self.remaining_time -= 1
        return self.remaining_time <= 0

    def is_finished(self):
        return self.remaining_time <= 0

# Doubly linked-list implementation of memory nodes
class Node:
    def __init__(self):
        self.data = {}
        self.next = None
        self.prev = None

    def __getitem__(self, key):
        return self.data[key]

    def __setitem__(self, key, value):
        self.data[key] = value

    def __iter__(self):
        current = self
        while current:
            next = current.next
            yield current
            current = next

    @staticmethod
    def next(a):
        current = a
        while current:
            yield current
            current = current.next

    @staticmethod
    def prev(a):
        current = a
        while current:
            yield current
            current = current.prev

    @staticmethod
    def cut(a):
        a_prev, a_next = a.prev, a.next
        a.prev = None
        a.next = None
        if a_prev:
            a_prev.next = a_next
        if a_next:
            a_next.prev = a_prev
        return a

    @staticmethod
    def head(a):
        current = a
        while current.prev is not None:
            current = current.prev
        return current
    
    @staticmethod
    def tail(a):
        current = a
        while current.next is not None:
            current = current.next
        return current

    @staticmethod
    def insert_before(a, b):
        a_prev, a_next, b_prev, b_next = a.prev, a.next, b.prev, b.next
        Node.cut(b)
        b.prev = a_prev
        b.next = a
        a.prev = b
        if a_prev:
            a_prev.next = b
        if b_next:
            b_next.prev = b


    @staticmethod
    def insert_after(a, b):
        a_prev, a_next, b_prev, b_next = a.prev, a.next, b.prev, b.next
        Node.cut(b)
        b.prev = a
        b.next = a_next
        a.next = b
        if a_next:
            a_next.prev = b
        if b_prev:
            b_prev.next = b

class MemoryNode(Node):
    def __init__(self, size):
        super().__init__()
        self["size"] = size
        self["process"] = None

    def is_busy(self):
        return (self["process"]) and (not self["process"].is_finished())
        
class Memory:
    def __init__(self, size, show_visualization = False, event_callback: Callable[[MemoryNode,int,str,str],None]=None):
        self.size = size
        self.head = MemoryNode(size)
        self.show_visualization = show_visualization
        self.event_callback = event_callback

    def __str__(self):
        output = "Memory Visualization:\n"
        output += "---------------------\n"

        current = self.head
        total_size = self.size
        occupied_size = 0
        visited = set()

        while current and current not in visited:
            visited.add(current)

            size = current["size"]
            process = current["process"]

            if process:
                output += f"[P{process.symbol}: {size} (T={process.remaining_time})]"
                occupied_size += size
            else:
                output += f"[Free: {size}]"

            current = current.next

        if current:  # Check if cycle was detected
            output += "\n*** Cycle detected in memory structure! ***\n"
        output += "\n---------------------\n"
        output += f"Total Size: {total_size}, Occupied: {occupied_size}, Free: {total_size - occupied_size}\n"
        return output

    def refresh_head(self):
        self.head = Node.head(self.head)

    def coalesce(self):
        moves = 0
        subset_head = None
        for node in self.head:
            if not node.is_busy():
                if not subset_head:
                    subset_head = node
                    subset_head["process"] = None
                else:
                    Node.cut(node)
                    subset_head["size"] += node["size"]
                    moves += 1
            else:
                subset_head = None
        self.refresh_head()
        if moves > 0:
            self.visualize(f"CH", f"Coalescing free adjacent blocks")
        return moves

    def compact(self):
        moves = 0
        anchor = None
        for node in self.head:
            if not node.is_busy():
                if not anchor:
                    anchor = node
            elif anchor:
                if anchor.prev:
                    for prev_node in Node.prev(anchor.prev):
                        if prev_node["process"].size <= node["process"].size:
                            Node.cut(node)
                            Node.insert_before(prev_node, node)
                            break
                else:
                    Node.cut(node)
                    Node.insert_before(anchor, node)
                moves += 1
                self.visualize(f"SC", f"Compacting blocks")

        self.refresh_head()
        return moves

    def allocate(self, process):
        if process.size > self.size:
            return None
        
        for node in self.head:
            if (not node.is_busy()) and (node["size"] >= process.size):
                if node["size"] > process.size:
                    Node.insert_after(node, MemoryNode(node["size"] - process.size))
                    node["size"] = process.size
                node["process"] = process
                return True
        self.refresh_head()
        return None

    def deallocate(self, process):
        for node in self.head:
            if node["process"] == process:
                node["process"] = None
                return

    def visualize(self, visualize_symbol, description):
        if self.event_callback:
            self.event_callback(self.head, self.size, visualize_symbol, description)

        if not self.show_visualization:
            return

       pass #implement next time

class ProcessorEvent:
    def __init__(self, head, time, symbol, description):
        self.head = head
        self.time = time
        self.symbol = symbol
        self.description = description

class Processor:
    def __init__(self, processes, memory_size, coalescing_time, compacting_time, event_listener: Callable[[ProcessorEvent], None]=None, show_plot=True):
        self.processes = processes
        self.memory = Memory(memory_size,
                             show_plot,
                             lambda head,time,sym,desc: event_listener(ProcessorEvent(head,time,sym,desc)))
        self.coalescing_time = coalescing_time
        self.compacting_time = compacting_time

    def run(self):
        current_time = 0
        finished_processes = {}
        active_processes = {}
        queue_processes = {i: v for i,v in enumerate(self.processes)}
        process_counter = 0

        while active_processes or queue_processes:
            time.sleep(0)
            # print(f"Current Time: {current_time}")
            # print(f"Process Counter: {process_counter}")

            # 1. coalescing and compacting
            # print("1")
            coalesce_compact = False
            if current_time % self.compacting_time == 0:
                # print("a")
                moves = self.memory.compact()
                if moves > 0:
                    coalesce_compact = True
                    current_time += 1
            if current_time % self.coalescing_time == 0:
                # print("b")
                moves = self.memory.coalesce()
                if moves > 0:
                    coalesce_compact = True
                    current_time += 1
            if coalesce_compact:
                # print("1.2")
                continue
        
            # 2. iterate all unallocated processes
            # print("2")
            allocated = None
            for i, q_process in queue_processes.items():
                if self.memory.allocate(q_process):
                    # print("c")
                    active_processes[i] = q_process
                    del queue_processes[i]
                    allocated = i
                    break

            # 3. round robin
            # print("3")
            current_process = None
            process_counter = ((process_counter+1) % (len(self.processes)+1)-1)
            if allocated:
                current_process = active_processes[allocated]
            elif len(active_processes) > 0:
                current_process = active_processes.get(process_counter)
            if current_process:
                # print("d")
                current_process.tick()
                # print(current_process.remaining_time)
                current_time += 1
                if allocated is None:
                    self.memory.visualize(f"J{current_process.symbol}", f"Running Process P{current_process.symbol} (Size: {current_process.size})")
                else:
                    self.memory.visualize(f"A{q_process.symbol}", f"Allocated Process P{q_process.symbol} (Size: {q_process.size})")
                if current_process.is_finished():
                    # print("e")
                    finished_processes[process_counter] = current_process
                    if active_processes.get(process_counter):
                        del active_processes[process_counter]
                    self.memory.deallocate(current_process)

            if allocated is None:
                process_counter += 1