waveterm/aiprompts/blockcontroller-lifecycle.md
Mike Sawka ff9923f486
Session Durability Checkpoint (#2821)
Working on bug fixes and UX. Streams restarting, fixed lots of bugs,
timing issues, concurrency bugs. Get status shipped to the FE to drive
"shield" state display. Deal with stale streams.

Also big UX changes to the block headers. Specialize the terminal
headers to prioritize the connection (sense of place), remove old
terminal icon and word "Terminal" from the header. Also drop "Web" and
"Preview" labels on web/preview blocks.

Added `wsh focusblock` command.
2026-02-03 11:49:52 -08:00

10 KiB

Block Controller Lifecycle

Overview

Block controllers manage the execution lifecycle of terminal shells, commands, and other interactive processes. The frontend drives the controller lifecycle - the backend is reactive, creating and managing controllers in response to frontend requests.

Controller States

Controllers have three primary states:

  • init - Controller exists but process is not running
  • running - Process is actively running
  • done - Process has exited

Architecture Components

Backend: Controller Registry

Location: pkg/blockcontroller/blockcontroller.go

The backend maintains a global controller registry that maps blockIds to controller instances:

var (
    controllerRegistry = make(map[string]Controller)
    registryLock       sync.RWMutex
)

Controllers implement the Controller interface:

  • Start(ctx, blockMeta, rtOpts, force) - Start the controller process
  • Stop(graceful, newStatus) - Stop the controller process
  • GetRuntimeStatus() - Get current runtime status
  • SendInput(input) - Send input (data, signals, terminal size) to the process

Frontend: View Model

Location: frontend/app/view/term/term-model.ts

The TermViewModel manages the frontend side of a terminal block:

Key Atoms:

  • shellProcFullStatus - Holds the current controller status from backend
  • shellProcStatus - Derived atom for just the status string ("init", "running", "done")
  • isRestarting - UI state for restart animation

Event Subscription: The constructor subscribes to controller status events (line 317-324):

this.shellProcStatusUnsubFn = waveEventSubscribe({
    eventType: "controllerstatus",
    scope: WOS.makeORef("block", blockId),
    handler: (event) => {
        let bcRTS: BlockControllerRuntimeStatus = event.data;
        this.updateShellProcStatus(bcRTS);
    },
});

This creates a reactive data flow: backend publishes status updates → frontend receives via WebSocket events → UI updates automatically via Jotai atoms.

Lifecycle Flow

1. Frontend Triggers Controller Creation/Start

Entry Point: ResyncController() RPC endpoint

The frontend calls this via RpcApi.ControllerResyncCommand when:

  1. Manual Restart - User clicks restart button or presses Enter when process is done

    • Triggered by forceRestartController()
    • Passes forcerestart: true flag
    • Includes current terminal size (termsize: { rows, cols })
  2. Connection Status Changes - Connection becomes available/unavailable

    • Monitored by TermResyncHandler component
    • Watches connStatus atom for changes
    • Calls termRef.current?.resyncController("resync handler")
  3. Block Meta Changes - Configuration like controller type or connection changes

    • Happens when block metadata is updated
    • Backend detects changes and triggers resync

2. Backend Processes Resync Request

The ResyncController() function:

func ResyncController(ctx context.Context, tabId, blockId string, 
                      rtOpts *waveobj.RuntimeOpts, force bool) error

Steps:

  1. Get Block Data - Fetch block metadata from database
  2. Determine Controller Type - Read controller meta key ("shell", "cmd", "tsunami")
  3. Check Existing Controller:
    • If controller type changed → stop old, create new
    • If connection changed (for shell/cmd) → stop and restart
    • If force=true → stop existing
  4. Register Controller - Add to registry (replaces existing if present)
  5. Check if Start Needed - If status is "init" or "done":
    • For remote connections: verify connection status first
    • Call controller.Start(ctx, blockMeta, rtOpts, force)
  6. Publish Status - Controller publishes runtime status updates

Important: Registering a new controller automatically stops any existing controller for that blockId (line 95-98):

if existingController != nil {
    existingController.Stop(false, Status_Done)
    wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId))
}

3. Backend Publishes Status Updates

Controllers publish their status via the event system when:

  • Process starts
  • Process state changes
  • Process exits

The status includes:

  • shellprocstatus - "init", "running", or "done"
  • shellprocconnname - Connection name being used
  • shellprocexitcode - Exit code when done
  • version - Incrementing version number for ordering

4. Frontend Receives and Processes Updates

Status Update Handler (line 321-323):

handler: (event) => {
    let bcRTS: BlockControllerRuntimeStatus = event.data;
    this.updateShellProcStatus(bcRTS);
}

Status Update Logic (line 430-438):

updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) {
    if (fullStatus == null) return;
    const curStatus = globalStore.get(this.shellProcFullStatus);
    // Only update if newer version
    if (curStatus == null || curStatus.version < fullStatus.version) {
        globalStore.set(this.shellProcFullStatus, fullStatus);
    }
}

The version check ensures out-of-order events don't cause issues.

5. UI Updates Reactively

The UI reacts to status changes through Jotai atoms:

Header Buttons (line 263-306):

  • Show "Play" icon when status is "init"
  • Show "Refresh" icon when status is "running" or "done"
  • Display exit code/status icons for cmd controller

Restart Behavior (line 631-635 in term.tsx via term-model.ts):

const shellProcStatus = globalStore.get(this.shellProcStatus);
if ((shellProcStatus == "done" || shellProcStatus == "init") && 
    keyutil.checkKeyPressed(waveEvent, "Enter")) {
    this.forceRestartController();
    return false;
}

Pressing Enter when the process is done/init triggers a restart.

Input Flow

Frontend → Backend:

When user types in terminal, data flows through sendDataToController():

sendDataToController(data: string) {
    const b64data = stringToBase64(data);
    RpcApi.ControllerInputCommand(TabRpcClient, { 
        blockid: this.blockId, 
        inputdata64: b64data 
    });
}

This calls the backend SendInput() function which forwards to the controller's SendInput() method.

The BlockInputUnion supports three types of input:

  • inputdata - Raw terminal input bytes
  • signame - Signal names (e.g., "SIGTERM", "SIGINT")
  • termsize - Terminal size changes (rows/cols)

Key Design Principles

1. Frontend-Driven Architecture

The frontend has full control over controller lifecycle:

  • Creates controllers by calling ResyncController
  • Restarts controllers via forcerestart flag
  • Monitors status via event subscriptions
  • Sends input via ControllerInput RPC

The backend is stateless and reactive - it doesn't make lifecycle decisions autonomously.

2. Idempotent Resync

ResyncController() is idempotent - calling it multiple times with the same state is safe:

  • If controller exists and is running with correct type/connection → no-op
  • If configuration changed → replaces controller
  • If force flag set → always restarts

This makes it safe to call on various triggers (connection change, focus, etc.).

3. Versioned Status Updates

Status includes a monotonically increasing version number:

  • Frontend can process events out-of-order
  • Only applies updates with newer versions
  • Prevents race conditions from concurrent updates

4. Automatic Cleanup

When a controller is replaced:

  • Old controller is automatically stopped
  • Runtime info is cleaned up
  • Registry entry is updated atomically

The registerController() function handles this automatically (line 84-99).

Common Patterns

Restarting a Controller

// In term-model.ts
forceRestartController() {
    this.triggerRestartAtom();  // UI feedback
    const termsize = {
        rows: this.termRef.current?.terminal?.rows,
        cols: this.termRef.current?.terminal?.cols,
    };
    RpcApi.ControllerResyncCommand(TabRpcClient, {
        tabid: globalStore.get(atoms.staticTabId),
        blockid: this.blockId,
        forcerestart: true,
        rtopts: { termsize: termsize },
    });
}

Handling Connection Changes

// In term.tsx - TermResyncHandler component
React.useEffect(() => {
    const isConnected = connStatus?.status == "connected";
    const wasConnected = lastConnStatus?.status == "connected";
    if (isConnected == wasConnected && curConnName == lastConnName) {
        return;  // No change
    }
    model.termRef.current?.resyncController("resync handler");
    setLastConnStatus(connStatus);
}, [connStatus]);

Monitoring Status

// Status is automatically available via atom
const shellProcStatus = jotai.useAtomValue(model.shellProcStatus);

// Use in UI
if (shellProcStatus == "running") {
    // Show running state
} else if (shellProcStatus == "done") {
    // Show restart button
}

Summary

The block controller lifecycle is frontend-driven and event-reactive:

  1. Frontend triggers controller creation/restart via ControllerResyncCommand RPC
  2. Backend processes the request in ResyncController(), creating/starting controllers as needed
  3. Backend publishes status updates via WebSocket events
  4. Frontend receives status updates and updates Jotai atoms
  5. UI reacts automatically to atom changes via React components

This architecture gives the frontend full control over when processes start/stop while keeping the backend focused on process management. The event-based status updates create a clean separation of concerns and enable real-time UI updates without polling.