Research: Technical
Architecture overview, polling mechanism, and task scheduling.
1. Codebase Overview
lofigui is a lightweight web-UI framework with dual Python and Go implementations. It provides a print-like interface for building server-side rendered HTML applications.
Architecture
The framework has three layers:
-
Buffer Layer (
lofigui.go/context.py,print.py,markdown.py) — Global mutable buffer that accumulates HTML fragments viaPrint(),Markdown(),HTML(),Table()calls. Python uses anasyncio.Queuethat drains into a string buffer; Go uses astrings.Builderdirectly. -
Controller Layer (
controller.go/controller.py) — Wraps a template engine (pongo2 in Go, Jinja2 in Python). Renders templates with the buffer content injected as{{ results | safe }}. -
App Layer (
app.go/app.py) — Manages controller lifecycle, action state (running/stopped), and auto-refresh polling. Implements the "singleton active model" concept — only one background task should run at a time.
Execution Patterns
| Pattern | Flow | Examples |
|---|---|---|
| Async + Polling | GET / resets buffer, starts model in goroutine/background task, redirects to /display. /display includes <meta http-equiv="Refresh"> tag while polling is active. |
01 (Go+Python) |
| Synchronous | Model runs inline, EndAction() called immediately, redirects to /display. |
02 (Go+Python) |
| WASM | Go compiled to WebAssembly, no server-side app. | 03, 04 |
| CRUD | Form POSTs modify state, redirect to GET /. Uses ctrl.StateDict() + ctrl.RenderTemplate() directly. |
06 (Go+Python) |
2. Notification System (Polling/Auto-Refresh)
The "notification system" is a server-side polling mechanism using HTML <meta http-equiv="Refresh"> tags. There is no WebSocket, SSE, or JavaScript-based notification — it's pure HTTP redirect-based polling.
How It Works
- Start:
app.StartAction()setsactionRunning=true,polling=true,PollCount=0. - Poll Cycle: On each
/displayrequest,app.StateDict()(Python) checksself.poll:- If
True: injects<meta http-equiv="Refresh" content="N">into therefreshtemplate variable. Incrementspoll_count. - If
False: setsrefresh="", resetspoll_count=0.
- If
- Stop:
app.EndAction()setsactionRunning=false,polling=false. Next/displayrequest renders without the refresh tag — polling stops. - Template: The template must include
{{ refresh | safe }}in the<head>for auto-refresh to work.
Python Implementation Details
App.state_dict()builds the full context dict includingrefresh,polling,poll_count.App.template_response()callsstate_dict()then renders via Jinja2.- The Python path works correctly:
template_response()->state_dict()-> injects refresh meta tag.
Go Implementation Details
App.StateDict()is the method that should build the full context with polling info.Controller.StateDict()only returnsrequestandresults(no polling info).App.HandleDisplay()delegates toctrl.HandleDisplay()which only usesctrl.StateDict().
Startup Bounce (Python only)
The Python App has a startup flag. On the first call to template_response(), if the URL path isn't /, it redirects to /. This prevents a stale /display page from showing when the server restarts. The startup_bounce_count limit of 3 is effectively unreachable because startup is set to False on the first invocation.
3. Task Scheduling Flow
Go Flow (async pattern)
User -> GET / -> app.HandleRoot(w, r, model, true)
1. RLock -> read ctrl, displayURL -> RUnlock
2. ctrl.context.Reset()
3. app.StartAction() <- sets actionRunning=true, polling=true
4. go modelFunc(app) <- goroutine launched, no cancellation mechanism
5. Write redirect HTML to /display
Background goroutine:
model(app) {
lofigui.Print("...")
time.Sleep(...)
app.EndAction() <- sets actionRunning=false, polling=false
}
User -> GET /display -> app.HandleDisplay(w, r)
1. RLock -> read ctrl -> RUnlock
2. ctrl.HandleDisplay(w, r, nil) <- uses ctrl.StateDict (NOT app.StateDict)
4. Handle (Single-Endpoint Pattern)
Handle(model) returns an http.HandlerFunc that manages the full lifecycle on a single URL. It uses buffer state as a signal:
GET / (buffer empty, not running) → start model in goroutine, render with Refresh header
GET / (running) → render current buffer (polling continues)
GET / (not running, buffer has content) → render final output, no refresh
State machine
| Buffer | Running | Action |
|---|---|---|
| empty | false | Start model, render with polling |
| any | true | Render current output, keep polling |
| content | false | Render final output, stop polling |
When the model goroutine returns normally, flush() is called which:
- Calls
EndAction()— stops polling, cancels context - If
LOFIGUI_HOLDis not set: waits 2 seconds (grace period for browser to pick up final render), then signals server shutdown viasignalDone() - If
LOFIGUI_HOLDis set: returns immediately — server stays alive
Restart behaviour
With Handle, the model only starts when the buffer is empty and no action is running. After the model completes, the buffer retains content, so subsequent requests just render the final output — no automatic restart. For restart support, use HandleRoot + HandleDisplay instead (the model can include a link to HandleRoot's URL).
5. HOLD Mode
Setting LOFIGUI_HOLD=1 keeps the server running after the model completes. This is designed for screenshot capture with tools like url2svg.
How it works
// In NewApp():
hold: os.Getenv("LOFIGUI_HOLD") != "",
// In flush():
func (app *App) flush() {
app.EndAction()
if app.hold {
return // keep server running
}
time.Sleep(2 * time.Second) // grace period
app.signalDone() // trigger server shutdown
}
Usage for screenshot capture
# Start server — stays alive after model completes
LOFIGUI_HOLD=1 go run .
# In another terminal, capture with url2svg
url2svg --url http://localhost:1340 -o screenshot.svg
The docs:capture:* Taskfile tasks automate this: start server in background, trigger the model, capture at timed intervals, then kill the server.
6. Cancellation Flow
Cancellation is transparent — model code does not need explicit cancel handling. The framework uses a panic-recover mechanism with an internal sentinel type.
Trigger
- Server:
HandleCancel(redirectURL)callsEndAction(), setscancelled=true, redirects, and triggers graceful shutdown - WASM:
goCancel()callsEndAction() - Restart:
StartAction()cancels the previous action's context before starting a new one
Mechanism
EndAction() / StartAction()
→ cancels context via cancelFunc()
→ next Print(), Sleep(), or Yield() call checks context
→ panics with cancelledError{} sentinel
→ Handle/HandleRoot's recover wrapper catches it
→ goroutine exits cleanly
Detail
EndAction()callscancelFunc(), setting the context to donedefaultContextstores the action context;checkCancelled()checks itPrint(),Sleep(),Yield()all callcheckCancelled()— if the context is done, theypanic(errCancelled)- The
go func()inHandle/HandleRoothasdefer recover()that catchescancelledErrorand returns silently - The buffer retains whatever was printed before cancellation — this becomes the final output
Server shutdown after cancel
When using app.ListenAndServe, HandleCancel triggers the same graceful shutdown as normal model completion:
EndAction()cancels the context and stops pollingcancelledflag is set on the app- The HTTP redirect is sent to the client
- After a 2-second grace period,
signalDone()triggerssrv.Shutdown() ListenAndServereturnsErrCancelled(notnil) — allowing the caller to exit with a non-zero status
if err := app.ListenAndServe(":1340", nil); err != nil {
if errors.Is(err, lofigui.ErrCancelled) {
log.Println(err)
os.Exit(1)
}
log.Fatal(err)
}
Normal completion returns nil (exit 0). Cancel returns ErrCancelled (exit 1).
Overriding cancel behaviour
For long-running servers that should keep running after cancel (e.g. HTMX apps, multi-page dashboards), write a custom handler instead of using HandleCancel:
http.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
app.EndAction()
http.Redirect(w, r, "/", http.StatusSeeOther)
})
This calls EndAction() (stops the model) without triggering server shutdown.
Buffer state after cancel
After cancellation, the buffer has partial content and actionRunning is false. With Handle, this means subsequent requests render the partial output as the "done" state (no restart, no polling).
Python Flow (async pattern)
User -> GET / -> root(background_tasks)
1. lg.reset()
2. background_tasks.add_task(model)
3. app.start_action() <- sets _action_running=True, poll=True
4. Return redirect HTML to /display
Background task:
model() {
lg.print("...")
sleep(...)
app.end_action() <- sets _action_running=False, poll=False
}
User -> GET /display -> display(request)
1. app.template_response(request, "hello.html")
-> app.state_dict(request) <- includes refresh meta tag if poll=True
-> templates.TemplateResponse(...)