ggblab Architecture

This document describes the design rationale and implementation details of ggblab’s communication architecture.

Communication Architecture Overview

ggblab implements a dual-channel communication design to enable seamless interaction between the GeoGebra applet (frontend) and Python kernel (backend) while working around inherent limitations of Jupyter’s IPython Comm.

The Challenge: IPython Comm Limitation

IPython Comm, the standard Jupyter communication protocol, has a critical limitation: it cannot receive messages while a notebook cell is executing. This presents a problem for interactive geometric applications where:

  • User code might be running a long computation or animation loop

  • The GeoGebra applet needs to send responses or updates back to Python

  • Real-time bidirectional communication is essential for interactive workflows

Solution: Dual-Channel Design

ggblab addresses this limitation with two complementary communication channels:

Channel 1: IPython Comm (Primary Channel)

Technology: IPython Comm over WebSocket
Managed by: Jupyter/JupyterHub infrastructure
Purpose: Main control channel

Responsibilities

  • Command and function call dispatch from Python → GeoGebra

  • Event notifications from GeoGebra → Python (object add/remove/rename, dialogs)

  • Configuration and initialization messages

  • Heartbeat and status monitoring

Infrastructure Guarantees

The IPython Comm channel benefits from Jupyter/JupyterHub’s robust infrastructure:

  • WebSocket management: Jupyter maintains the WebSocket connection

  • Reverse proxy support: Works seamlessly in JupyterHub deployments with reverse proxies

  • Connection health: Jupyter/JupyterHub guarantees connection integrity and automatic reconnection

  • Security: Authentication and authorization handled by Jupyter

Known Limitation

Cannot receive during cell execution: When a Python cell is running (e.g., a for loop or await statement), IPython’s event loop is blocked and cannot process incoming Comm messages. This prevents real-time responses from the applet during long-running operations.

Channel 2: Out-of-Band Socket (Secondary Channel)

Technology: Unix Domain Socket (POSIX) / TCP WebSocket (Windows)
Managed by: ggblab backend (ggb_comm)
Purpose: Response delivery during cell execution

Responsibilities

  • Deliver GeoGebra API responses when the primary Comm channel is blocked

  • Enable await ggb.function(...) calls to complete even during cell execution

  • Support interactive operations in animation loops or long-running code

Design Rationale

Why Unix Domain Socket on POSIX?

  • Performance: Lower latency than TCP for local inter-process communication

  • Security: File system permissions control access; no network exposure

  • Simplicity: No port conflicts or firewall configuration needed

Why TCP WebSocket on Windows?

  • Cross-platform compatibility: Windows lacks first-class Unix Domain Socket support in some environments

  • Consistent API: Browser WebSocket API works identically for both transport types

  • Portability: Ensures ggblab works on Windows without degraded functionality

Connection Model: Transient, Per-Transaction

Unlike the persistent IPython Comm connection, the out-of-band channel:

  1. Opens a fresh connection for each send_recv() call

  2. Transmits the response from GeoGebra → Python

  3. Closes immediately after delivery

Advantages:

  • No persistent connection to maintain

  • No reconnection logic needed (connection failure = transaction failure, simple retry)

  • Minimal resource overhead (connections are short-lived)

  • Natural backpressure: one pending response per transaction

Why no auto-reconnection?

  • The connection is transient by design—each transaction creates a new connection

  • If a transaction fails, the caller (Python code) receives an exception and can retry

  • The primary Comm channel (managed by Jupyter) handles persistent connectivity

Command Validation (Pre-Flight Checks)

Before sending commands to GeoGebra, ggblab performs optional validation to catch errors early and provide Python-side feedback instead of relying on GeoGebra’s timeout-based error signaling.

Syntax Validation

Purpose: Verify command strings can be parsed into valid tokens

Implementation (ggblab/ggbapplet.py):

if self.check_syntax:
    try:
        self.parser.tokenize_with_commas(c)
    except Exception as e:
        raise GeoGebraSyntaxError(c, str(e))

What it checks:

  • Command string can be tokenized by the parser

  • Parentheses, brackets, and braces are balanced

  • Basic lexical structure is valid

What it does NOT check:

  • Command name existence (GeoGebra may support commands not in the parser’s command cache)

  • Argument count or types

  • Semantic correctness (use check_semantics for that)

Usage:

ggb = await GeoGebra().init()
ggb.check_syntax = True  # Enable syntax validation

try:
    await ggb.command("A=(0,0)")  # Valid
except GeoGebraSyntaxError as e:
    print(f"Syntax error: {e}")

Raises: GeoGebraSyntaxError if tokenization fails

Semantic Validation

Purpose: Verify referenced objects exist in the applet before sending the command

Status: Partial implementation (see limitations below)

Implementation (ggblab/ggbapplet.py):

if self.check_semantics:
    try:
        # Refresh object cache from applet
        await self.refresh_object_cache()
        
        # Extract object tokens: tokens that are
        # not commands (not in command_cache), not commas, and not literals
        t = self.parser.tokenize_with_commas(c)
        object_tokens = [o for o in flatten(t) 
                        if o not in self.parser.command_cache 
                        and o != ","
                        and not self._is_literal(o)]
        
        # Check if referenced objects exist
        missing_objects = [obj for obj in object_tokens 
                          if obj not in self._applet_objects]
        
        if missing_objects:
            raise GeoGebraSemanticsError(
                c, 
                f"Referenced object(s) do not exist in applet: {missing_objects}",
                missing_objects
            )
    except GeoGebraSemanticsError:
        raise
    except Exception as e:
        raise GeoGebraSemanticsError(c, f"Validation error: {e}")

What it checks:

  • Object references in the command exist in the applet’s object cache

  • Refreshes the cache before checking to catch recent additions/deletions

What it does NOT check (limitations):

  • Command name validity (if check_syntax passes, command is assumed valid)

  • Argument types or counts (would require full GeoGebra API metadata)

  • Scope/visibility (static analysis cannot determine runtime scope)

  • Overload resolution (multiple command signatures not distinguished)

  • N-ary dependencies (3+ objects creating a single dependent object)

Why incomplete: GeoGebra does not maintain a public, versioned, machine-readable command schema. The official GitHub repository is outdated and does not reflect the live API. Maintaining a static schema would be error-prone and fragile.

Usage:

ggb = await GeoGebra().init()
ggb.check_semantics = True  # Enable semantic validation

# Attempt to use non-existent object
try:
    await ggb.command("Circle(A, 2)")  # A does not exist
except GeoGebraSemanticsError as e:
    print(f"Semantic error: {e}")
    print(f"Missing objects: {e.missing_objects}")

Raises: GeoGebraSemanticsError if referenced objects don’t exist

Cache Management

Object Cache:

  • Initialized on GeoGebra().init() via refresh_object_cache()

  • Updated after each successful command() execution

  • Can be manually refreshed: await ggb.refresh_object_cache()

Cache Accuracy:

  • Reflects the current applet state at check time

  • May become stale if objects are added/removed via:

    • Frontend UI (direct user actions in GeoGebra)

    • Multiple Python kernels (if multiple notebooks control the same applet)

  • Calling refresh_object_cache() explicitly ensures fresh data

Trade-off: Prevents false positives (rejecting valid commands) at the cost of occasional false negatives (accepting commands that reference recently-deleted objects, which will timeout).

Validation Strategy

Recommended practice:

# Enable both checks for maximum safety
ggb.check_syntax = True
ggb.check_semantics = True

try:
    await ggb.command("Circle(A, Distance(A, B))")
except GeoGebraSyntaxError:
    print("Command syntax is invalid")
except GeoGebraSemanticsError as e:
    print(f"Objects not found: {e.missing_objects}")
except TimeoutError:
    # Command may have been rejected by GeoGebra despite passing pre-flight checks
    # Check recv_events for error dialogs
    print("Command timed out or was rejected by GeoGebra")

Validation Flow:

Python command(c)
    ↓
check_syntax enabled? → tokenize → SyntaxError
    ↓ (pass)
check_semantics enabled? → refresh cache → extract tokens → check existence → SemanticError
    ↓ (pass)
Send to GeoGebra via out-of-band socket
    ↓
GeoGebra processes (may still fail internally)
    ↓
Timeout after 3 seconds? → Check recv_events for error events
    ↓
Errors found? → GeoGebraAppletError
    ↓
No errors? → Return value or None

Runtime Error Handling: GeoGebraAppletError

Purpose: Capture errors that occur during GeoGebra execution, not during pre-flight validation

How it works:

  1. Asynchronous error capture: GeoGebra error events ({'type': 'Error', 'payload': '...'}) are queued via the out-of-band socket

  2. Multiple error consolidation: Consecutive error events are automatically combined into a single exception

  3. Timeout-triggered check: When send_recv() times out waiting for a response, it checks recv_events for accumulated error messages

  4. Empty response handling: If the response arrives but the payload is empty (None), a 0.5-second wait allows additional errors to arrive before checking

Exception hierarchy:

GeoGebraError (base)
├── GeoGebraCommandError (pre-flight validation)
│   ├── GeoGebraSyntaxError
│   └── GeoGebraSemanticsError
└── GeoGebraAppletError (runtime, from applet)

Usage:

from ggblab.errors import GeoGebraAppletError

try:
    await ggb.command("Unbalanced(")
except GeoGebraAppletError as e:
    print(f"Applet error: {e.error_message}")
    print(f"Error type: {e.error_type}")

Example error flow:

GeoGebra applet receives: "Unbalanced("
    ↓
Applet generates error events:
    {'type': 'Error', 'payload': 'Unbalanced brackets '}
    {'type': 'Error', 'payload': 'Unbalanced( '}
    ↓
send_recv() waits for response (doesn't arrive)
    ↓
Timeout triggers recv_events check
    ↓
Errors found and combined:
    "Unbalanced brackets \nUnbalanced( "
    ↓
GeoGebraAppletError raised with combined message

Data Flow Diagrams

Normal Command Execution (Primary Channel)

Python Kernel                    Frontend (Browser)
     |                                  |
     |  1. command("A=(0,0)")           |
     |  2. Syntax & semantic checks     |
     |  3. Send via IPython Comm        |
     |--------------------------------->|
     |      via IPython Comm            |
     |                                  |
     |                      2. Execute GeoGebra command
     |                                  |
     |  3. Response (label)             |
     |<---------------------------------|
     |      via IPython Comm            |
     |                                  |

Function Call During Cell Execution (Dual Channel)

Python Cell (running)            Frontend (Browser)            ggb_comm (backend)
     |                                  |                              |
     |  1. await function("getValue")   |                              |
     |--------------------------------->|                              |
     |      via IPython Comm            |                              |
     |                                  |                              |
     |  (Python blocked, cannot receive)|                              |
     |                                  |                              |
     |                      2. Call GeoGebra API                       |
     |                                  |                              |
     |                      3. Response ready                          |
     |                                  |                              |
     |                                  |  4. Open out-of-band socket  |
     |                                  |----------------------------->|
     |                                  |                              |
     |  5. Response delivered           |                              |
     |<-----------------------------------------------------------------|
     |      via Unix socket / WebSocket |                              |
     |                                  |                              |
     |  (await completes)               |  6. Close connection         |
     |                                  |<-----------------------------|

Error Event Capture (Dual Channel)

Python Cell (running)            Frontend (Browser)            ggb_comm (backend)
     |                                  |                              |
     |  1. command("Unbalanced(")       |                              |
     |--------------------------------->|                              |
     |                                  |                              |
     |                      2. Execute → Error!
     |                                  |                              |
     |                                  |  3. Queue error events       |
     |                                  |----------------------------->|
     |                                  |  Error event #1              |
     |                                  |  Error event #2              |
     |  (Python blocked waiting)        |                              |
     |                                  |                              |
     |  4. Timeout after 3 seconds      |                              |
     |                                  |                              |
     |  5. Check recv_events            |                              |
     |<----|  Retrieve error events     |                              |
     |     |  Combine messages          |                              |
     |     |  Raise GeoGebraAppletError |                              |

Implementation Details

Backend: ggb_comm (ggblab/comm.py)

Responsibilities:

  • Start Unix socket server (POSIX) or TCP WebSocket server (Windows)

  • Register IPython Comm target (ggblab-comm), kept singular because IPython Comm cannot receive during cell execution and multiplexing via multiple targets would not solve that constraint

  • Provide send_recv(msg) API that:

    1. Sends msg via IPython Comm to frontend

    2. Waits for response on the out-of-band socket

    3. Returns response to caller

Server Initialization:

async def server(self):
    if os.name in ['posix']:
        # Unix Domain Socket
        _fd, self.socketPath = tempfile.mkstemp(prefix="/tmp/ggb_")
        os.close(_fd)
        os.remove(self.socketPath)
        async with unix_serve(self.client_handle, path=self.socketPath) as self.server_handle:
            await asyncio.Future()  # Run indefinitely
    else:
        # TCP WebSocket
        async with serve(self.client_handle, "localhost", 0) as self.server_handle:
            self.wsPort = self.server_handle.sockets[0].getsockname()[1]
            await asyncio.Future()

Client Handler:

async def client_handle(self, client_id):
    self.clients.add(client_id)
    try:
        async for msg in client_id:
            _data = json.loads(msg)
            _id = _data.get('id')
            
            # Route event-type messages to recv_events queue
            # Messages with 'id' are command responses; messages without 'id' are events.
            if _id:
                # Response message: store in recv_logs for send_recv() to retrieve
                self.recv_logs[_id] = _data['payload']
            else:
                # Event message: queue for event processing
                self.recv_events.put(_data)
    finally:
        self.clients.remove(client_id)

Message Routing Strategy:

  • Responses (with id): Keyed by message ID in recv_logs for send_recv() to retrieve

  • Events (without id): Queued in recv_events for asynchronous event processing

This enables real-time error event capture and dialog message delivery during cell execution.

Frontend: Widget Connection Logic (src/widget.tsx)

Comm Setup:

const comm = kernel.createComm(props.commTarget || 'ggblab-comm');
comm.open('HELO from GGB').done;

comm.onMsg = async (msg) => {
    const command = JSON.parse(msg.content.data as any);
    // Execute command or function
    // ...
    // Send response back via out-of-band socket if available
    if (socketPath || wsPort) {
        await sendViaSocket(response);
    }
};
### Widget Launch Strategy and Applet Parameter Limitations

GeoGebra applets expose a limited set of startup parameters, documented at:

- https://geogebra.github.io/docs/reference/en/GeoGebra_App_Parameters/

In practice, only `appletOnLoad` provides a JavaScript hook at load time; other parameters do not allow passing dynamic kernel communication configuration to the widget. Additionally, launching from the JupyterLab Launcher or Command Palette supplies fixed arguments only, which prevents injecting per-session communication details before the widget is created.

To ensure the kernelwidget communication is configured before initialization, ggblab launches the widget programmatically from a notebook cell using ipylab:

1. The Python helper `GeoGebra().init()` prepares communication settings (Comm target, socket path/port) in the kernel.
2. It then triggers the frontend command `ggblab:create` via ipylab with the prepared settings.
3. The widget initializes with the provided configuration, enabling immediate two-way communication.

This strategy avoids the limitations of Launcher/Command Palette (fixed args) and the applet parameter model, guaranteeing reliable setup for the dual-channel communication described above.

Out-of-Band Socket Connection (per response):

// Pseudo-code (actual implementation uses kernel2.requestExecute)
if (socketPath) {
    ws = unix_connect(socketPath);
} else {
    ws = connect(`ws://localhost:${wsPort}/`);
}
ws.send(JSON.stringify(response));
ws.close();

Message ID Correlation

To match responses with requests when multiple operations are in flight:

  1. Backend generates unique id for each send_recv() call (UUID)

  2. Frontend receives command with id in the Comm message

  3. Frontend includes same id in response sent via out-of-band socket

  4. Backend matches response by id in recv_logs dictionary

Error Handling

Primary Channel (IPython Comm) Error Handling

Responsibility: Jupyter/JupyterHub infrastructure
Status: Robust and automatic

The IPython Comm channel inherits error handling from Jupyter:

  • Connection errors: Jupyter detects WebSocket failures and handles reconnection

  • Message delivery: Guaranteed via Jupyter’s message queuing and acknowledgment

  • User notification: Connection status visible in JupyterLab UI (kernel indicator)

  • Recovery: Automatic reconnection when connection is lost and restored

No explicit error handling required in ggblab for the primary channel.

Out-of-Band Channel Error Handling

Responsibility: ggblab backend and frontend
Status: Timeout-based with event queueing

The out-of-band channel operates independently with dual responsibilities:

1. Response Delivery (Timeout-Based)

The out-of-band socket has a 3-second timeout for command responses:

# In ggblab/comm.py send_recv()
try:
    async with asyncio.timeout(3.0):
        # Wait for response to arrive via out-of-band socket
        while not (_id in self.recv_logs):
            await asyncio.sleep(0.01)
        value = self.recv_logs.pop(_id, None)
        return value
except TimeoutError:
    print(f"TimeoutError in send_recv {msg}")
    return { 'type': 'error', 'message': 'TimeoutError in send_recv' }

If no response arrives within 3 seconds, a timeout error is returned.

2. Event Delivery (Queue-Based)

Real-time events (error dialogs, object notifications) are captured and queued via the out-of-band socket:

# In frontend widget.tsx
const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
        mutation.addedNodes.forEach((node) => {
            try {
                // Detect GeoGebra error dialogs
                (node as HTMLElement).querySelectorAll('div.dialogMainPanel > div.dialogTitle').forEach((n) => {
                    const msg = JSON.stringify({
                        "type": n.textContent,  // e.g., "Error", "Warning"
                        "payload": n2.textContent
                    });
                    // Send via both channels during cell execution
                    comm.send(msg);  // Primary channel (blocked during execution)
                    await callRemoteSocketSend(kernel2, msg, socketPath, wsUrl);  // Out-of-band channel
                });
            } catch (e) { /* handle */ }
        });
    });
});

Backend event processing:

# Events arrive via out-of-band socket without 'id' field
if not _id:
    self.recv_events.put(_data)  # Queue for later processing

Python code can then drain the event queue after commands complete:

# Future implementation: Process queued events
while not self.comm.recv_events.empty():
    event = self.comm.recv_events.get_nowait()
    if event['type'] == 'Error':
        print(f"GeoGebra error: {event['payload']}")

GeoGebra API Constraint: No Explicit Error Responses

Critical limitation: The GeoGebra API does NOT provide explicit error response codes or callbacks for invalid commands.

This means:

  • When a command fails (e.g., invalid syntax, reference to non-existent object), GeoGebra does not send an error response via the out-of-band socket

  • No error codes, error messages, or structured error data are returned

  • The only signals are:

    1. Timeout after 3 seconds (command was rejected silently)

    2. Error dialog popup (captured and forwarded via out-of-band socket)

Example:

# This will timeout because GeoGebra sends no response for invalid commands
try:
    result = await applet.evalCommand("DeleteObject(NonExistent)")
except TimeoutError:
    print("GeoGebra rejected the command (no explicit error returned)")
    # Check if an error dialog was posted
    if not applet.comm.recv_events.empty():
        event = applet.comm.recv_events.get_nowait()
        if event['type'] == 'Error':
            print(f"Error details: {event['payload']}")

Error Handling Summary

Channel

Error Detection

Delivery

Recovery

IPython Comm

Jupyter infrastructure

Command dispatch

Jupyter handles reconnection

Out-of-band socket (responses)

3-sec timeout

Message ID correlation

TimeoutError exception to Python

Out-of-band socket (events)

Event queue

Type-based routing

Queue processing via recv_events

GeoGebra API

Dialog popups

DOM mutation observer

Dialog events forwarded to Python

Current Limitations:

  • Non-dialog errors result in timeout with minimal context

  • Response timeout is fixed at 3 seconds (not configurable)

Future Error Handling Improvements (v0.8.x)

To improve error handling on the out-of-band channel:

  1. Event Queue Processing

    • Drain recv_events queue after command execution

    • Extract error dialogs and parse for context information

    • Return structured error objects with type and message

  2. Custom Timeout Configuration

    • Allow GeoGebra(timeout=5.0) to set custom timeout per applet instance

    • Allow command(..., timeout=10.0) for command-specific timeout

  3. Dialog Message Extraction

    • Parse GeoGebra dialog DOM for structured error details

    • Map dialog types to error codes (e.g., “Syntax error”, “Undefined variable”)

    • Return error object with context to Python

  4. Dynamic Scope Learning from Errors

    • Capture error events in recv_events queue

    • Correlate with check_semantics validation logic

    • Refine validation rules based on actual GeoGebra responses

Resource Cleanup and Lifecycle Management

Graceful Shutdown

ggblab implements proper resource cleanup through the widget’s dispose() lifecycle hook:

Frontend Widget Disposal (src/widget.tsx):

dispose(): void {
    console.log("GeoGebraWidget is being disposed.");
    window.dispatchEvent(new Event('close'));
    super.dispose();
}

When the GeoGebra panel is closed:

  1. Widget disposal triggered: JupyterLab calls dispose() on the GeoGebraWidget instance

  2. Close event dispatched: window.dispatchEvent(new Event('close')) signals cleanup to any active listeners

  3. IPython Comm cleanup: The Comm connection is automatically closed by Jupyter/JupyterHub infrastructure when the widget is disposed

  4. Kernel resource release: The secondary kernel connection (used for out-of-band WebSocket setup) is released

Backend Resource Cleanup (ggblab/comm.py):

async def server(self):
    if os.name in ['posix']:
        # Unix Domain Socket with context manager
        async with unix_serve(self.client_handle, path=self.socketPath) as self.server_handle:
            await asyncio.Future()  # Run indefinitely
    else:
        # TCP WebSocket with context manager
        async with serve(self.client_handle, "localhost", 0) as self.server_handle:
            await asyncio.Future()

The out-of-band socket server uses async with context managers:

  • Automatic cleanup: Socket resources are released when the context exits

  • Per-transaction connections: Each message response opens and closes a connection, preventing resource leaks

  • No persistent state: No connection pooling or persistent connections to clean up

Resource Guarantees

Resource

Cleanup Mechanism

Status

IPython Comm

Jupyter/JupyterHub infrastructure

Automatic on widget disposal

Out-of-band socket connections

async with context manager

Automatic per-transaction cleanup

Secondary kernel connection

JupyterLab kernel manager

Released on widget disposal

WebSocket server

Python websockets library

Closed when context exits

Result: All communication resources are properly released when the GeoGebra panel is closed, with no resource leaks.

Security Considerations

Unix Domain Socket (POSIX)

  • File system permissions control access to the socket

  • Socket created in /tmp/ with restrictive permissions (default umask)

  • Only processes running as the same user can connect

  • No network exposure

TCP WebSocket (Windows)

  • Localhost binding only: Server binds to 127.0.0.1, not accessible from network

  • Dynamic port allocation: OS assigns available port, reducing conflicts

  • Ephemeral connections: Short-lived connections minimize attack surface

  • No authentication needed: Local-only communication between trusted processes

Jupyter Infrastructure

  • IPython Comm inherits Jupyter’s authentication and authorization

  • Token-based access control for WebSocket connections

  • HTTPS/WSS support in JupyterHub deployments

Scalability and Performance

Connection Overhead

Out-of-band channel:

  • Connection setup: ~1-5ms (Unix socket) or ~5-10ms (TCP localhost)

  • Data transfer: minimal overhead for small JSON payloads

  • Connection teardown: immediate

Trade-off: Slightly higher per-call overhead vs. persistent connection, but gains:

  • No connection pooling or lifecycle management

  • No reconnection logic complexity

  • Natural cleanup on process termination

Concurrency

IPython Comm: Single-threaded by design (IPython event loop)
Out-of-band socket: Async/await pattern, multiple pending responses possible

Limitation: Singleton GeoGebra instance per kernel session
Rationale: Avoids complexity of managing multiple Comm targets and socket servers

Future Enhancements

Potential Improvements

  1. Connection pooling for out-of-band socket (reduce setup overhead)

  2. Compression for large payloads (e.g., Base64-encoded .ggb files)

  3. Binary protocol instead of JSON for performance-critical operations

  4. Multi-instance support with namespace isolation

Considered but Rejected

  1. WebRTC Data Channel: Too complex for local-only communication, browser API limitations

  2. Shared memory: Not portable across platforms, complex synchronization

  3. HTTP polling: Higher latency and overhead than WebSocket

Testing Strategies

Unit Tests (v0.7.3 - COMPLETE)

Backend Test Suite (tests/):

  1. Parser Tests (tests/test_parser.py):

    • 18 test classes, 70+ test methods

    • Dependency graph construction and analysis

    • Topological sorting, generations, reachability analysis

    • Edge cases: empty constructions, single objects, N-ary dependencies

    • Performance tests: 30+ independent objects, linear chains

    • All tests with cache_enabled=False for isolation

  2. GeoGebra Applet Tests (tests/test_ggbapplet.py):

    • 6 test classes, 16 test methods

    • Singleton initialization and state management

    • Syntax/semantic validation with mocked applet

    • Object cache management and None-response handling

    • Literal detection (numeric, string, boolean, math functions)

    • Exception handling: GeoGebraSyntaxError, GeoGebraSemanticsError

  3. Construction File Handling (tests/test_construction.py):

    • 5 test classes, 20+ test methods

    • File loading: .ggb (ZIP), .ggb (Base64), JSON, XML

    • File saving: Round-trip integrity, format preservation

    • Scientific notation handling (implementation-aware testing)

Coverage:

  • pytest tests/ --cov=ggblab --cov-report=html

  • Coverage metrics automatically uploaded to Codecov on CI

Integration Tests (GitHub Actions)

CI/CD Pipeline (.github/workflows/tests.yml):

  • Automated on every push to main/dev branches

  • Automated on all pull requests

  • Multi-platform testing:

    • Ubuntu (Linux), macOS, Windows

    • Python 3.10, 3.11, 3.12

  • 30 test matrix combinations automatically executed

  • Coverage reports uploaded to Codecov

Running Tests Locally:

# Install test dependencies
pip install -e ".[dev]"
pip install pytest pytest-cov

# Run all tests with coverage
pytest tests/ -v --cov=ggblab --cov-report=html

# Run specific test class
pytest tests/test_parser.py::TestDependencyGraphConstruction -v

# Run with XML output (for CI integration)
pytest tests/ --junitxml=junit.xml --cov=ggblab --cov-report=xml

Browser/Integration Tests (Playwright/Galata)

Not yet implemented - planned for v0.8+

  • Full browser + kernel workflow validation

  • Command execution during idle kernel

  • Function calls during long-running cell

  • Multiple rapid function calls (concurrency)

  • Socket reconnection after backend restart

See ui-tests/README.md for setup instructions.

Platform-Specific Tests (via CI)

  • POSIX: Unix socket creation and permissions tested on Ubuntu/macOS

  • Windows: TCP WebSocket fallback behavior tested on Windows


Dependency Parser Architecture

Overview

The ggb_parser module (ggblab/parser.py) analyzes object relationships in GeoGebra constructions by building directed graphs using NetworkX. It provides two graph representations:

  1. G (Full Dependency Graph): Complete construction dependencies

  2. G2 (Simplified Subgraph): Minimal construction sequences

Current Implementation: parse_subgraph()

The parse_subgraph() method attempts to identify minimal construction sequences by enumerating all possible combinations of root objects and their dependencies.

Known Limitations

1. Combinatorial Explosion (Critical Performance Issue)

The method generates all possible combinations of root objects:

_paths = []
for __p in (list(chain.from_iterable(combinations(_nodes1, r)
            for r in range(1, len(_nodes1) + 1)))):
    _paths.append(_nodes0 | set(__p))
  • If there are n root objects, this generates $2^n - 1$ potential paths

  • With 20+ roots: ~1 million paths to evaluate

  • With 30+ roots: ~1 billion paths — computation becomes intractable

Impact: Large constructions with many independent objects (e.g., multiple input points, parameters) will cause significant performance degradation or hang.

Workaround: Limit analysis to constructions with <15 independent root objects.

2. Infinite Loop Risk

The iteration condition depends on _nodes1 being updated:

while _nodes1:
    # ... processing ...
    _nodes1 = _nodes3 - _nodes2 - _nodes1

Under certain graph topologies, _nodes1 may not change, causing the loop to iterate infinitely or until Python resource limits are hit.

3. Limited Handling of N-ary Dependencies

The current match statement only handles 1-ary and 2-ary dependencies:

match len(_nodes2 - _nodes0):
    case 1:
        # Handle single parent
        self.G2.add_edge(o, n)
    case 2:
        # Handle two parents
        self.G2.add_edge(o1, n)
        self.G2.add_edge(o2, n)
    case _:
        pass  # Silently ignore 3+ parents

Missing: Constructions where 3+ objects jointly create a dependent object (e.g., a triangle from 3 points, or a polygon from multiple vertices) are not represented in G2.

4. Redundant Neighbor Computation

Inside the inner loop:

for n1 in _nodes2:
    _n = [set(self.G.neighbors(__n)) for __n in _nodes2]  # Computed every iteration

The neighbors list is recalculated on each iteration of n1, even though it’s independent of n1. This is $O(n)$ redundant work per iteration.

5. Debug Output in Production Code
print(f"found: '{o}' => '{n}'")
print(f"found: '{o1}', '{o2}' => '{n}'")

These debug statements appear in every edge discovery and should be removed for production use or wrapped in a configurable debug flag.

Testing

Current testing coverage for parse_subgraph() is minimal. Recommended test cases:

# test_parser.py
def test_parse_subgraph_simple():
    """Single dependency chain: A -> B -> C"""
    # Expected: G2 has edges A->B, B->C
    
def test_parse_subgraph_diamond():
    """Diamond dependency: A,B -> C -> D"""
    # Expected: G2 has edges A->C, B->C, C->D
    
def test_parse_subgraph_binary_tree():
    """Binary tree of dependencies"""
    # Expected: linear time, no combinatorial explosion
    
def test_parse_subgraph_large():
    """Large graph with 50+ nodes"""
    # Expected: completes within 5 seconds
    
def test_parse_subgraph_nary_deps():
    """3+ parents creating single output: A,B,C -> D"""
    # Expected: G2 has edges A->D, B->D, C->D

Asyncio Design Challenges in Jupyter

The Core Problem: Jupyter + Asyncio Impedance Mismatch

Python’s asyncio module is widely used but has significant limitations in the Jupyter Kernel environment:

  1. IPython Comm Cannot Receive During Cell Execution

    • While a cell is running, the IPython event loop is blocked

    • Incoming Comm messages cannot be processed until the cell completes

    • This is a fundamental architectural constraint, not a bug

  2. Asyncio Requires Explicit Yield Points

    • Every await statement is a potential yield point where other tasks can run

    • Without explicit await asyncio.sleep() calls, asyncio has no opportunity to switch tasks

    • Most developers are unaware of this requirement

  3. No Native Event-Based Waiting

    • asyncio lacks a clean way to wait for dictionary updates or queue population

    • Current ggblab implementation uses polling: while not (_id in self.recv_logs): await asyncio.sleep(0.01)

    • This is inefficient and inelegant compared to event-based systems (e.g., threading.Event)

Evidence from ggblab/comm.py

Problematic code pattern:

async def send_recv(self, msg):
    _id = str(uuid.uuid4())
    self.send(msg)
    
    # Polling loop: check every 10ms if response arrived
    async def wait_for_response():
        while not (_id in self.recv_logs):
            await asyncio.sleep(0.01)  # <-- Explicit yield point required!
    
    await asyncio.wait_for(wait_for_response(), timeout=3.0)
    value = self.recv_logs.pop(_id, None)

Why this is suboptimal:

  • Busy-waiting simulation: Polls every 10ms instead of waiting for an event

  • Arbitrary sleep time: 0.01 seconds is a guess; too short = CPU waste, too long = latency

  • No condition variable: Threading has threading.Event and threading.Condition; asyncio has no equivalent

  • Inefficient for Jupyter: The Jupyter event loop should be managing concurrency, not application code

Alternative (if asyncio had better primitives):

# This would be cleaner (but asyncio doesn't provide it natively)
response_event = asyncio.Event()

def on_response_received(id):
    response_event.set()

await asyncio.wait_for(response_event.wait(), timeout=3.0)

Why This Matters for Language Selection

This complexity reveals why TypeScript/Node.js backend might have been technically superior:

Aspect

Python asyncio

Node.js async/await

Event loop

Complex, user must manage

Simple, built-in, always running

Waiting for events

Manual polling required

async/await + Promise chains

Blocking during cell execution

Blocks Jupyter event loop

Would block Node.js event loop (similar issue)

Learning curve

High; requires deep understanding

Medium; familiar to web developers

Operational context

Not standard in Jupyter

Even less standard in Jupyter

Key insight: The problem isn’t Python’s asyncio per se—it’s that any framework must bridge the gap between Jupyter’s execution model and concurrent communication. TypeScript wouldn’t solve this; it just moves the problem to a less-familiar runtime.

Deployment Reality: Why Python Wins Despite Complexity

Despite asyncio’s technical shortcomings, Python remains the better choice because:

  1. Jupyter already assumes Python: Most institutional deployments have Python; Node.js doesn’t

  2. Users expect Python: ggblab students are already Python programmers

  3. Complexity is hidden: Users don’t see comm.py; they call await ggb.command()

  4. Works well enough: Even with polling, the 10ms cycle is imperceptible to users

The lesson: Operational constraints (kernel availability) trump technical elegance (language features).

Recommendations for Future Work

Current Implementation: Best Practical Solution

The current implementation using polling with explicit await asyncio.sleep(0.01) is the best practical solution given Jupyter’s constraints:

Why asyncio.Event doesn’t solve the problem:

  • asyncio.Event has been tested extensively but does not circumvent the IPython Comm limitation

  • The issue is not waiting mechanism (Event vs polling)—it’s that IPython’s event loop itself is blocked during cell execution

  • When a cell runs, IPython’s event loop cannot process any incoming Comm messages, regardless of how elegantly the backend waits

  • Polling is necessary because Comm messages may arrive via the out-of-band socket at unpredictable times

Why threading doesn’t work:

  • Threading would require the Comm handler to run in a different thread, creating race conditions

  • IPython Comm operations are not thread-safe; they assume single-threaded kernel execution

  • Refactoring to thread-safe Comm would require changes to IPython itself

Current design is robust:

  • ✅ Polling with 0.01s sleep is imperceptible to users (10ms is below human reaction time)

  • ✅ Timeout-based fallback (3 seconds) is sufficient for interactive operations

  • ✅ Event queue (recv_events) properly captures async error events

  • ✅ Dual-channel architecture elegantly sidesteps the IPython Comm blocking limitation

Conclusion: The current implementation is not a compromise—it’s the optimal solution given the architectural constraints of Jupyter and IPython Comm.

Global Scope Buffer Requirement

A critical constraint often missed: asyncio data exchange buffers must be class variables (global scope), not instance variables.

Current implementation (ggblab/comm.py):

class ggb_comm:
    # These MUST be at class scope, not instance scope
    recv_logs = {}          # Response storage by message ID
    recv_events = queue.Queue()  # Error event queue
    logs = []               # Diagnostic logs

Why class scope is required:

  1. Multiple async tasks access the same buffers:

    • client_handle() (server connection handler) populates recv_logs and recv_events

    • send_recv() (command sender) reads from recv_logs and recv_events

    • Both run concurrently in the same event loop

  2. Async task isolation prevents instance variable sharing:

    • Each await point creates a suspension boundary

    • If buffers were instance variables, different async tasks would be looking at different dictionaries

    • This breaks message correlation

  3. Event loop singleton:

    • There is one event loop per Python kernel process

    • ggb_comm is instantiated once per kernel

    • Putting buffers at class scope ensures they persist across all await points and async task switches

Example of what FAILS with instance variables:

class ggb_comm_broken:
    def __init__(self):
        self.recv_logs = {}  # ❌ Instance variable
        self.recv_events = queue.Queue()  # ❌ Instance variable

    async def send_recv(self, msg):
        _id = str(uuid.uuid4())
        self.send(msg)
        
        # This checks a DIFFERENT recv_logs than client_handle() populates!
        while not (_id in self.recv_logs):  # ❌ Sees empty dict
            await asyncio.sleep(0.01)
        
        # Message ID never arrives because it went to a different dictionary

Why this fails:

  • Instance variables are created per object instance

  • self.recv_logs refers to the instance’s dictionary

  • client_handle() may be running in a different async task context

  • No guarantee that send_recv()’s self refers to the same object as client_handle()’s self

  • Even if it does, the timing of async task scheduling can cause data races

Proper design with class variables:

class ggb_comm:
    # Class variables: shared across all async tasks
    recv_logs = {}          # All tasks see the same dict
    recv_events = queue.Queue()  # All tasks see the same queue
    
    async def send_recv(self, msg):
        _id = str(uuid.uuid4())
        self.send(msg)
        
        # This checks THE SAME recv_logs that client_handle() populates
        while not (_id in self.recv_logs):  # ✅ Sees shared dict
            await asyncio.sleep(0.01)

Reference:


Long-Term: Jupyter Kernel Architecture Evolution

True improvement would require changes at the Jupyter/IPython level:

  1. IPython Comm receives during cell execution: Requires IPython architecture redesign (unlikely)

  2. Async-first kernel: Redesign kernel messaging to be fully asynchronous (ambitious, long-term)

  3. Separate kernel-side event loop: Run communication on a different event loop than cell execution (complex isolation required)

These are beyond the scope of ggblab and would require Jupyter/IPython community effort.


Design Decision: Backend Language Selection (Python vs. TypeScript)

Context: Why Python for ggb_comm.py?

The backend communication handler (ggblab/comm.py) is implemented in Python, even though the frontend is TypeScript/React. This decision involves trade-offs worth documenting for future maintainers.

Option Analysis

Option A: Python Backend (Current Choice)

Advantages:

  • Kernel ecosystem: Jupyter kernels for Python are ubiquitous; most Jupyter environments have Python available

  • Asyncio maturity: Python’s asyncio is well-documented and battle-tested for educational purposes

  • Single language for data science stack: Scientific computing typically uses Python (NumPy, SciPy, SymPy, etc.)

  • Wider adoption: Python dominates STEM education; ggblab students are already Python programmers

  • Package distribution: PyPI distribution is straightforward; pip install handles versioning

Disadvantages:

  • Runtime dependency: Python must be installed and available in the Jupyter environment

  • Version management: Requires Python 3.10+; older environments may not have it

  • Kernel startup overhead: Python kernel startup is slower than lightweight runtimes

Option B: TypeScript/Node.js Backend

Hypothetical advantages if implemented:

  • Single language: Frontend and backend in same language (DRY principle)

  • Code sharing: Message types, validation logic could be reused via TypeScript interfaces

  • Lighter runtime: Node.js faster startup than Python

  • NPM distribution: Familiar to JavaScript ecosystem

Critical disadvantages:

  • Kernel availability: Jupyter Node.js kernels are not standard. Most Jupyter installations lack Node.js runtime

  • Deployment complexity: Users would need to install Node.js separately or use alternative kernels (like ijavascript or jp-ts)

  • Educational friction: Students expect Python in Jupyter; adding Node.js requirement increases setup complexity

  • Version parity problem: Frontend (TypeScript) and backend (Node.js) would be separate versioned products with sync requirements

  • Kernel infrastructure: Standard Jupyter assumes Python kernel; Node.js kernels require additional setup

  • IPython Comm: While IPython Comm is language-agnostic, Node.js kernels have variable support quality

Operational Constraints

Jupyter Kernel Availability

Current reality:

  • Python kernel: Always present in any Jupyter installation (assumption: Jupyter >= 4.0)

  • Node.js kernel: Optional, requires separate installation and configuration

  • R kernel: Common but optional

  • Julia kernel: Rare, requires additional setup

Implication: ggblab’s Python backend ensures the extension works out-of-the-box. Users don’t need to think about runtime selection.

Deployment Context

JupyterHub in Educational Settings:

  • Sysadmins control what kernels are available

  • Adding Node.js requirement would require admin approval and installation

  • Creates additional maintenance burden on institutions

Cloud Deployments (Google Colab, JupyterHub SaaS):

  • Colab provides Python by default; Node.js not available

  • Enterprise JupyterHub usually provides Python only (Typescript/Node optional)

  • Python backend maximizes compatibility

The Communication Stack

Despite the backend being Python, the communication stack is language-neutral:

Frontend (TypeScript)  ←→  IPython Comm (JSON messages)  ←→  Backend (Python async)
    ↓                                                           ↓
GeoGebra API                                          Kernel + socket server
    ↓                                                           ↓
    └─────── Out-of-band socket (WebSocket/Unix) ──────────────┘
                   (Protocol-agnostic transport)

This design means:

  • ✅ Frontend can be written in any language (TypeScript chosen for React ecosystem)

  • ✅ Backend can be written in any Jupyter-supported language (Python chosen for ubiquity)

  • ✅ Communication protocol is language-independent (JSON + WebSocket)

Lessons Learned

Key insight: Tight coupling of frontend and backend languages (TypeScript for both) is not justified when:

  1. The kernel availability determines deployment success more than implementation language

  2. The target audience (Python programmers) expects Python

  3. The communication protocol is already language-agnostic

  4. Code sharing benefits are minimal (message types are simple JSON, validation logic is kernel-specific)

Recommendation for future changes:

  • Keep frontend in TypeScript (React ecosystem is mature; switching gains nothing)

  • Keep backend in Python (addresses operational constraints; switching to Node.js creates problems)

  • If non-Python kernel support is desired, implement additional kernels (e.g., Julia, R) via separate language-specific packages, not by switching the reference implementation

TypeScript Backend: When It Could Work

TypeScript backend would be viable only if:

  1. Standard Node.js kernel emerges as Jupyter standard (unlikely)

  2. Deployment targets only advanced users who already manage Node.js (educational loss)

  3. Code sharing between frontend and backend is critical (currently minimal benefit)

None of these conditions are currently met, so Python remains the better choice.


Non-Python Kernel Support (Julia, R, etc.)

Protocol Portability

The ggblab communication architecture is language-agnostic at the protocol level:

  • IPython Comm: Supported by any Jupyter kernel (uses JSON messages)

  • WebSocket/Unix Socket: Language-independent transport (any language can open sockets)

  • Message Format: JSON-based, not Python-specific

However, implementing language-specific client libraries requires addressing several challenges.

Critical Implementation Notes for Non-Python Kernels

1. Asynchronous Execution Model Must Match

Challenge: Different languages have different async patterns.

Aspect

Python

Julia

R

Async syntax

async/await

@async / Task

future / promise-like

Blocking behavior

await blocks on Awaitable

wait() on Task

resolve() on future

Event loop

asyncio.run()

@async tasks

Single-threaded

Multiple concurrent operations

Multiple await in same scope

Multiple tasks in same scope

Parallel evaluation or callbacks

Recommendation: Implement send_recv() with the language’s native async primitives, not by wrapping Python’s asyncio.

Example (Julia pseudocode):

async function send_recv(msg::Dict)::Dict
    id = uuid4()
    put!(comm_channel, merge(msg, Dict("id" => id)))
    
    # Wait for response with matching id
    while true
        response = take!(response_channel)  # Blocking wait
        if response["id"] == id
            return response
        end
    end
end

2. Message ID Correlation

Requirement: Backend and frontend must exchange id fields to correlate responses with requests.

Format (JSON):

{
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "type": "command",
    "payload": "A=(0,0)",
    ...
}

All languages must:

  1. Generate a unique id (UUID/UUID4) for each send_recv() call

  2. Include id in the Comm message to frontend

  3. Receive response with matching id via out-of-band socket

  4. Match response by id before returning to caller

Error case: If response arrives with wrong id, queue it and continue waiting. This handles concurrent requests.

3. Out-of-Band Socket Connection (Language-Specific)

Python: Uses asyncio WebSocket client (see ggblab/comm.py)

Julia: Use Julia’s WebSocket library:

using WebSockets
ws = WebSocket("ws://localhost:$port")  # TCP on Windows
# or
ws = connect(socket_path)  # Unix socket on POSIX (if library supports)

R: Use R’s WebSocket library:

library(websocket)
ws <- WebSocket$new("ws://localhost:port")

Critical: Each send_recv() must open a separate, transient connection for that specific request. Do NOT maintain a persistent connection.

4. IPython Comm Target Registration

Requirement: Register a Comm target handler to receive commands from frontend.

The Comm target name must be ggblab-comm (hardcoded in frontend).

Python implementation (reference):

def comm_handler(comm, open_msg):
    @comm.on_msg
    def _recv(msg):
        content = msg['content']['data']
        # Process command, store response for out-of-band delivery

Julia equivalent:

# Julia kernels expose Comm via kernel API
kernel_comm = kernel.comm
comm_handler = message -> begin
    # Process message
end
register_comm("ggblab-comm", comm_handler)

R equivalent:

# R kernels may use IRkernel package
IRkernel::register_comm("ggblab-comm", function(msg) {
    # Process message
})

Note: Exact API varies by language. Consult Jupyter kernel documentation for your language.

5. Object Cache Management (Language-Dependent)

Challenge: Python uses a dictionary; other languages may prefer different data structures.

Requirements (language-independent):

  • Store GeoGebra object names and metadata

  • Refresh from applet via evalCommand("GetValue('_json')")

  • Check object existence before sending commands (optional but recommended)

Python reference:

self._applet_objects = {}  # Dict[name, metadata]

async def refresh_object_cache(self):
    json_str = await self.function("getBase64", [])
    # Parse and store
    self._applet_objects = parse_geogebra_json(json_str)

6. Error Handling (Critical Differences)

Key constraint: GeoGebra sends NO explicit error responses for invalid commands.

Error detection mechanisms (all languages):

  1. Timeout after 3 seconds: Command was rejected or crashed GeoGebra

  2. Error dialog events: Captured by frontend and queued in recv_events

  3. Response with no payload: May indicate error (GeoGebra silently failed)

Error handling pattern (pseudo-code):

try:
    result = send_recv(command)
catch TimeoutError:
    # Command rejected or GeoGebra crashed
    check recv_events for error dialog
    if error_event found:
        raise GeoGebraAppletError(error_event.message)
    else:
        raise TimeoutError("No response from GeoGebra")

Recommendation: Implement error classes isomorphic to Python’s:

  • GeoGebraError (base)

    • GeoGebraCommandError (pre-flight)

      • GeoGebraSyntaxError

      • GeoGebraSemanticsError

    • GeoGebraAppletError (runtime)

7. Configuration and Initialization

Requirement: The kernel must receive communication settings (Comm target, socket path/port) from the frontend before issuing commands.

Currently: Python uses GeoGebra().init() which:

  1. Sets up Comm handler for ggblab-comm

  2. Starts out-of-band socket server

  3. Triggers frontend command ggblab:create with socket path/port

  4. Waits for frontend to confirm connection before returning

For other languages: Implement similar initialization:

  • Register Comm target

  • Start socket server

  • Store path/port for send_recv() to use

  • Confirm ready before accepting commands

Example (Julia):

mutable struct GeoGebra
    comm_channel::Channel
    response_channel::Channel
    socket_path::String
    socket_port::Int
    
    function init()
        # Register Comm, start socket, trigger frontend
        # Return instance
    end
end

Summary

Non-Python kernel support is technically feasible but requires:

  • Careful async/await pattern translation

  • Proper UUID-based message ID correlation

  • Robust timeout and error event handling

  • Language-specific Comm registration

  • Comprehensive protocol documentation

The core communication design (IPython Comm + out-of-band socket) poses no fundamental barriers to non-Python languages. The effort is primarily in documentation and reference implementation.


References