aiprompts/blockcontroller-lifecycle.md
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.
Controllers have three primary states:
init - Controller exists but process is not runningrunning - Process is actively runningdone - Process has exitedLocation: 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 processLocation: 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 animationEvent 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.
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
forceRestartController()forcerestart: true flagtermsize: { rows, cols })Connection Status Changes - Connection becomes available/unavailable
TermResyncHandler componentconnStatus atom for changestermRef.current?.resyncController("resync handler")Block Meta Changes - Configuration like controller type or connection changes
The ResyncController() function:
func ResyncController(ctx context.Context, tabId, blockId string,
rtOpts *waveobj.RuntimeOpts, force bool) error
Steps:
controller meta key ("shell", "cmd", "tsunami")force=true → stop existingcontroller.Start(ctx, blockMeta, rtOpts, force)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))
}
Controllers publish their status via the event system when:
The status includes:
shellprocstatus - "init", "running", or "done"shellprocconnname - Connection name being usedshellprocexitcode - Exit code when doneversion - Incrementing version number for orderingStatus 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.
The UI reacts to status changes through Jotai atoms:
Header Buttons (line 263-306):
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.
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)The frontend has full control over controller lifecycle:
The backend is stateless and reactive - it doesn't make lifecycle decisions autonomously.
ResyncController() is idempotent - calling it multiple times with the same state is safe:
This makes it safe to call on various triggers (connection change, focus, etc.).
Status includes a monotonically increasing version number:
When a controller is replaced:
The registerController() function handles this automatically (line 84-99).
// 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 },
});
}
// 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]);
// 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
}
The block controller lifecycle is frontend-driven and event-reactive:
ControllerResyncCommand RPCResyncController(), creating/starting controllers as neededThis 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.