Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

IPC

The flow CLI communicates with the flowd daemon over a single Windows named pipe using newline-delimited JSON. The daemon listens for one client at a time on a background accept thread, while the main IPC thread processes hook events and dispatches commands.

Named-Pipe Protocol

The IPC transport uses the Windows named pipe \\.\pipe\flow (configurable via the FLOW_PIPE_NAME environment variable for integration tests). The wire format is newline-delimited JSON: each message is a single JSON object terminated by \n, serialised by serde_json. The encoding and decoding helpers live in src/ipc/message.rs.

The pipe uses byte mode (PIPE_TYPE_BYTE | PIPE_READMODE_BYTE) with duplex access. The server accepts one client at a time (sequential, not concurrent). After processing a request, the server disconnects and waits for the next connection. The server is created with FILE_FLAG_FIRST_PIPE_INSTANCE so that a second daemon instance fails fast with an address-in-use error instead of silently replacing the first.

sequenceDiagram
    participant CLI as flow CLI
    participant Pipe as named pipe
    participant Accept as Accept Thread
    participant IPC as IPC Thread
    participant flow as FlowWM

    CLI->>Pipe: CreateFileW (overlapped)
    Accept->>Accept: ConnectNamedPipe (blocking)
    Accept->>IPC: SetEvent(connected_event)
    IPC->>IPC: WaitForMultipleObjects wakes
    IPC->>Pipe: read_message() -- blocking ReadFile
    Pipe-->>IPC: {"type":"focus_left"}\n
    IPC->>flow: dispatch(SocketMessage::FocusLeft)
    flow-->>IPC: SocketResponse::Ok
    IPC->>Pipe: write_response() -- WriteFile
    Pipe-->>CLI: {"status":"ok"}\n
    IPC->>Pipe: DisconnectNamedPipe
    IPC->>Accept: start_accept() -- next client

Why a Named Pipe

A named pipe was chosen over TCP or Unix-domain sockets for three reasons:

  • Local-only – named pipes are inherently local to the machine. No network exposure, no firewall rules, no port conflicts.
  • Windows-nativeCreateNamedPipeW / ConnectNamedPipe are the standard Win32 IPC primitives. No third-party dependency, no compatibility shim.
  • ACL-capable – the pipe can (and will, in a future hardening pass) accept a SECURITY_ATTRIBUTES structure to restrict access to the current user session. Currently any local process can connect (acceptable for the single-user MVP).

Client-Side Timeout

The client opens the pipe with FILE_FLAG_OVERLAPPED so every read/write is bounded by a 30-second deadline (IPC_TIMEOUT in src/ipc/transport.rs). If the daemon accepts the connection but never replies, CancelIo fires and the CLI returns a TimedOut error instead of hanging forever. Each overlapped operation uses a Win32 manual- reset event and WaitForSingleObject to await completion or cancellation. This prevents integration tests from stalling when the daemon is stuck in layout computation.

Server-Side Architecture

The server side in src/ipc/transport.rs uses synchronous (blocking) I/O – appropriate because the daemon’s clients are one- shot flow invocations that always send immediately. The PipeServer struct manages a background accept thread: start_accept() spawns a short-lived thread that blocks in ConnectNamedPipe, then signals a Win32 event to wake the main thread’s WaitForMultipleObjects. See threading model for how the connected event coexists with the hook signal in the main loop.

All pipe handles are wrapped in PipeHandle (RAII, closes on drop) and a similar EventHandle wrapper manages Win32 event handles, preventing kernel handle leaks on early returns.

Message Surface

SocketMessage (CLI -> Daemon)

Commands are grouped by purpose. All variants use serde’s externally-tagged enum format with a "type" field and snake_case variant names.

GroupVariantsDescription
ControlStop, ReloadConfig, CheckConfigDaemon lifecycle and config
FocusFocusLeft/Right/Up/DownMove focus between windows
Swap (per-window)SwapLeft/Right/Up/DownSwap focused window with neighbour
Swap (column)SwapColumn { direction }Swap entire focused column
Semantic moveMoveWindow { direction }Daemon resolves concrete action by state
ScrollScrollLeft, ScrollRightSlide the viewport
Column resizeExpandColumn, ShrinkColumn, SetColumnWidth { width_px }Adjust column widths
Window stateToggleFloat, ToggleMonocle, PlaceAbove, Promote, CloseWindowPer-window operations
WorkspaceSwitchWorkspace { workspace_id }, SwapWorkspace { workspace_id }, MoveWindowToWorkspace { workspace_id }niri-style virtual desktops (Switch/Move implemented; Swap stub)
QueryQueryWindowsAll, QueryLayoutVirtual, QueryLayoutActual, QueryStateRead-only introspection
Config mutationSetConfigValue { key, value }Runtime config change
App preferencesForgetApp { exe }, ForgetAllAppsClear per-app learned state

The MoveWindow variant is deliberately semantic: for tiled windows moving left or right it delegates to SwapColumn, but the daemon owns the translation so keybindings stay stable as floating support lands. Vertical movement (within-column swap) and floating-window nudging are deferred — only the tiled left/right path is wired today.

Of the three workspace variants, SwitchWorkspace and MoveWindowToWorkspace are fully implemented (see Workspace Hierarchy for the vertical-packing switch animation). Only SwapWorkspace returns unimplemented_command — its protocol shape is locked in, but its animation model (two workspaces exchanging positions in the packed stack) is still undecided.

SocketResponse (Daemon -> CLI)

VariantFieldsWhen
OkCommand succeeded
Errormessage: StringCommand failed
Datapayload: serde_json::ValueResponse to a query command

Responses use a "status" tag ("ok", "error", "data"), distinct from the message "type" tag. A response-shaped JSON will not parse as a SocketMessage and vice versa – the tests in src/ipc/message.rs enforce this explicitly.

The flow CLI Client

The CLI (src/bin/flow.rs) is built with clap and structured into four top-level command groups. Every dispatch command sends a single SocketMessage and prints a one-line success or error message. The CLI does no layout computation – it is a thin transport layer. See event pipelines for what happens on the daemon side after a command arrives.

Lifecycle

CommandAction
flow start [--config <dir>] [--log-file <path>] [--ahk]Spawn flowd.exe detached, poll until pipe is ready; with --ahk, also launch flow.ahk via its shell association
flow stop [--ahk]Send Stop via named pipe; with --ahk, also terminate the AutoHotkey process launched by start --ahk

flow start locates flowd.exe next to the current executable, spawns it with CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW (falling back to plain spawn() under WSL interop), then polls the pipe at 200 ms intervals for up to 5 seconds. The --config flag sets FLOW_CONFIG_DIR in the current process before spawning so the child inherits it; the --log-file flag is forwarded as a CLI argument.

The --ahk flag launches the user’s flow.ahk after the daemon is ready, so the first hotkey always reaches a listening daemon. AutoHotkey is resolved via ShellExecuteExW on the .ahk file (Windows’ shell association handles interpreter lookup); the launched PID is written to flow.ahk.pid in the config directory so flow stop --ahk can terminate exactly that process. stop --ahk runs after the daemon has stopped, so a live daemon never loses its keybindings mid-dispatch.

Autostart

CommandAction
flow enable-autostart [--ahk]Create a single flow.lnk in shell:startup that runs flow.exe start [--ahk]
flow disable-autostartRemove the flow.lnk shortcut (idempotent)

The autostart shortcut targets flow.exe start [--ahk] rather than flowd.exe directly, so login reuses the interactive startup path’s pre-flight config check and double-start guard – there is no parallel startup code path. The .lnk uses SW_HIDE to suppress the brief console window flow.exe allocates before it spawns the detached daemon and exits. disable-autostart takes no --ahk flag because there is only ever one shortcut.

Config

CommandAction
flow config initCreate config dir + write default files (no daemon contact)
flow config reloadSend ReloadConfig to daemon
flow config editOpen config dir in $EDITOR / VISUAL / notepad.exe
flow config pathPrint resolved config directory path
flow config checkValidate config files locally

All config commands except reload operate on local files without contacting the daemon. The config directory resolution chain is documented in config and persistence.

Query

CommandAction
flow query allSend QueryWindowsAll, pretty-print JSON response

Dispatch

CommandMaps to
flow dispatch focus left/right/up/downFocusLeft/Right/Up/Down
flow dispatch swap-column left/rightSwapColumn
flow dispatch move-window left/rightMoveWindow
flow dispatch expand-columnExpandColumn
flow dispatch shrink-columnShrinkColumn
flow dispatch close-windowCloseWindow
flow dispatch switch-workspace <id>SwitchWorkspace
flow dispatch swap-workspace <id>SwapWorkspace (stub)
flow dispatch move-to-workspace <id>MoveWindowToWorkspace

The dispatch commands that change layout flow through the daemon’s 3-stage pipeline (mutate, project, animate) as described in architecture. The CloseWindow command is a special case: it only queues WM_CLOSE and lets the EVENT_OBJECT_DESTROY hook handle the actual layout removal, avoiding a race between the synchronous IPC response and the asynchronous window destruction.

Graceful-Shutdown Window Rescue

flow stop sends the Stop command, the event loop exits, and then – before the daemon process tears down – [FlowWM::rescue_stranded_windows] runs once. Its job is to bring windows that became physically inaccessible during this flow session back onto the screen, without disturbing anything the user can currently see.

Why rescue is needed

flow parks non-active workspaces off-screen (one monitor height above or below the active one) and scrolls windows horizontally across the infinite tiling canvas. If the daemon simply exited, every window on a non-active workspace – plus any window scrolled into the off-screen packing region of the active workspace – would be left stranded where flow put it: visible to Win32 (so a future flow run would re-classify them as already-tiled) but unreachable to the user, who has no way to scroll to a workspace that no longer exists.

The visibility rule

The rescue pass treats the operating system as ground truth, not flow’s own bookkeeping. For each controlled window it calls GetWindowRect to find the window’s current rect, then asks: does the window’s visible content overlap the active monitor’s work_area?

  • Yes (visible) – the user can see this window right now. Leave it exactly where it is. The user has almost certainly rearranged things by hand since flow started; rewinding their work would be hostile.
  • No (stranded) – the window is off-screen purely because flow moved it. Bring it back.

The visible-content rect is the raw GetWindowRect rect with the window’s invisible borders stripped (InvisibleBounds::window_to_visible). This step is not cosmetic: GetWindowRect includes the ~7px invisible borders Windows 10/11 draw for drop shadows, while the layout engine parks columns flush against the work_area edge in visible coordinates. Without the conversion, a window parked exactly at the edge would bleed a few pixels of invisible border into the work_area and read as “visible” – so the rescue pass would leave it stranded in the packing region of the active workspace. Stripping the borders recovers the true visible rect, which only touches the edge and is correctly treated as stranded. See roadmap.md (“GetWindowRect Includes Invisible Borders”) for background.

Touching edges do not count as overlapping, matching Win32 IntersectRect semantics (see Rect::overlaps).

flowchart TD
    A[For each controlled window] --> B[GetWindowRect]
    B --> G[Strip invisible borders to visible-content rect]
    G --> C{Visible content overlaps work_area?}
    C -- yes: visible --> D[Leave it untouched]
    C -- no: stranded --> E[Clamp pre_manage_rect into work_area]
    E --> F[SetWindowPos to clamped anchor]

The rescue target: pre_manage_rect

Each controlled window remembers the rect it had when flow first noticed it (stored as pre_manage_rect at registration time). The rescue moves stranded windows back to that anchor, then clamps the anchor into the work_area in case the anchor itself is off-screen (e.g. the window came from a now-detached monitor, or the work area changed because the taskbar moved).

Clamping shrinks-and-shifts rather than discards: a window larger than the work area collapses to the work area’s size; otherwise the original size is preserved and only the position is nudged on-screen.

Tiles and floats are unified

There is no special-casing between tiling and floating: every window flow is actively positioning – a Tiling(Active) or Floating(Active) window – goes through the same loop. Minimized, Hidden, and Ignored windows never reach it. flow does not actively place minimized/hidden windows, so their position is the OS’s and the user’s responsibility; leaving them alone on shutdown avoids surprising the user by moving windows they intentionally hid.

The “pre-this-run” contract

pre_manage_rect is the position the window held before this flow run, not before the very first flow run in history. Consequences:

  • If the previous flow session was killed uncleanly (crash, taskkill /f, Ctrl-C), windows it had tiled remain at their tiled positions. The next run captures those as the new baseline. The desktop self-heals after one clean flow stop.
  • Minimized/hidden windows are out of scope entirely (see above); float windows lose any in-run drag history (acceptable for v1), restoring to their registration-time anchor.

The unclean-shutdown case – where the daemon process is gone and cannot run the rescue pass – is what the planned watchdog process is for: restoring from an on-disk snapshot without needing the daemon alive (see the Roadmap).

Code path

StepLocation
Wired after the event loop exitssrc/main.rsflow.run() then flow.rescue_stranded_windows()
The rescue loopsrc/daemon/shutdown.rs
Which windows are controlledWindowRegistry::restorable_windows in src/registry/core.rs
Clamp geometryRect::clamped_into in src/common/types.rs

Watchdog

A separate watchdog process is planned for crash recovery — restoring windows from an on-disk snapshot when the daemon dies unexpectedly. It is not yet implemented and is deliberately deferred until flow/flowd are feature-complete. See the Roadmap for the full design, including why it is a separate process rather than a Windows service.

Cross-References

  • Threading model – the IPC thread’s WaitForMultipleObjects loop and how pipe connections coexist with hook events
  • Event pipelines – the IPC command dispatch path through the 3-stage layout pipeline
  • Architecture – subsystem overview showing where the IPC server fits inside the daemon