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 (html/templatein Go, Jinja2 in Python). Renders templates with the buffer content injected as{{.results}}(Go, viatemplate.HTML) or{{ results | safe }}(Python). -
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. While the model runs, the server tells the browser to reload after N seconds. There's no WebSocket, SSE, or JavaScript timer — it's pure HTTP, driven by the server.
The two implementations diverge in how they tell the browser to reload:
| Go | Python | |
|---|---|---|
| Mechanism | Refresh: N HTTP header on the response |
<meta http-equiv="Refresh" content="N"> in the rendered HTML |
| Set by | App.WriteRefreshHeader(w) |
App.state_dict() populates refresh template var |
Template needs {{refresh}}? |
No — always empty (kept for back-compat) | Yes — must include `{{ refresh |
| Reload target | The current page URL (per HTTP semantics) | The current page URL (browser respects meta refresh) |
The header-based approach was adopted in Go because <meta http-equiv="Refresh"> reloads the URL the meta tag is in, which broke multi-page apps where the user navigates between routes (each page should refresh itself, not the original landing URL). The HTTP Refresh header naturally reloads whatever URL was requested.
How It Works
- Start:
app.StartAction()setsactionRunning=true,polling=true,PollCount=0. Establishes a cancellable context that flows through the buffer. - Poll Cycle: On each render request,
App.StateDict(r, extra)builds the template context withpolling="Running"and incrementsPollCount.App.WriteRefreshHeader(w)writes theRefresh: Nheader. - Stop:
app.EndAction()setsactionRunning=false,polling=false, cancels the context. The next render emits noRefreshheader — the browser stops polling.
Go Implementation Details
App.StateDict(r, extra)builds the full context:request,version,build_date,controller_name,results(astemplate.HTML),polling,poll_count, plusrefresh(always emptytemplate.HTML).Controller.StateDict(r)returns only the minimal pair (request,results) — used when rendering with no app state.App.HandleDisplay(w, r)andApp.Handle(model)both callApp.StateDictand render viactrl.RenderTemplate(w, data)directly, afterapp.WriteRefreshHeader(w)has set the header.
Python Implementation Details
App.state_dict()builds the context dict includingrefresh(the<meta>tag string),polling,poll_count.App.template_response()callsstate_dict()then renders via Jinja2.- Templates inject the meta tag with
{{ refresh | safe }}in<head>.
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 (HandleRoot + HandleDisplay pattern)
User -> GET / -> app.HandleRoot(w, r, model, true)
1. Lock -> ensureController, read displayURL -> Unlock
2. ctrl.context.Reset()
3. app.StartAction() <- sets actionRunning=true, polling=true,
establishes cancellable context
4. go modelFunc(app) <- goroutine wrapped with recover() for the
cancelledError sentinel
5. http.Redirect(w, r, "/display", 303)
Background goroutine:
modelFunc(app) {
lofigui.Print("...") // checkCancelled() runs first
app.Sleep(1 * time.Second) // panic(errCancelled) on cancel
app.EndAction() // sets actionRunning=false, polling=false
}
User -> GET /display -> app.HandleDisplay(w, r)
1. Lock -> ensureController -> Unlock
2. app.WriteRefreshHeader(w) <- emits "Refresh: N" while polling
3. data := app.StateDict(r, nil)
4. ctrl.RenderTemplate(w, data)
App.Handle(model) is a single-endpoint variant that uses buffer state to gate restarts (see §4 below). App.Run(addr, model) wires Handle+HandleCancel+ServeBulma and starts the server in one call — the simplest viable shape.
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(...)