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.
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 runningrunning- Process is actively runningdone- 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 processStop(graceful, newStatus)- Stop the controller processGetRuntimeStatus()- Get current runtime statusSendInput(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 backendshellProcStatus- 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:
-
Manual Restart - User clicks restart button or presses Enter when process is done
- Triggered by
forceRestartController() - Passes
forcerestart: trueflag - Includes current terminal size (
termsize: { rows, cols })
- Triggered by
-
Connection Status Changes - Connection becomes available/unavailable
- Monitored by
TermResyncHandlercomponent - Watches
connStatusatom for changes - Calls
termRef.current?.resyncController("resync handler")
- Monitored by
-
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:
- Get Block Data - Fetch block metadata from database
- Determine Controller Type - Read
controllermeta key ("shell", "cmd", "tsunami") - 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
- Register Controller - Add to registry (replaces existing if present)
- Check if Start Needed - If status is "init" or "done":
- For remote connections: verify connection status first
- Call
controller.Start(ctx, blockMeta, rtOpts, force)
- 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 usedshellprocexitcode- Exit code when doneversion- 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 bytessigname- 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:
- Frontend triggers controller creation/restart via
ControllerResyncCommandRPC - Backend processes the request in
ResyncController(), creating/starting controllers as needed - Backend publishes status updates via WebSocket events
- Frontend receives status updates and updates Jotai atoms
- 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.