tbbroker is a small twinBASIC program that hands out what the tools need to start an IDE or a
program out of the user's sight --- a private desktop, a slot number to name things by, and a
port when one must be chosen in advance --- and takes each back when the processes using it end.
Every tool on the machine asks the same broker: the Node tools of every worktree, tb-launch.ps1
through them, and launcher-re's tblaunch. This file is the contract a client is written from,
then the reasons.
Desktops leak in DWM. On Windows 10 22H2 (19045) every CreateDesktop leaves composition
objects in dwm.exe that are never freed after the desktop is gone (about two handles per
desktop), and DWM walks that state on every window change. The tools made one desktop per launch
(tbbuild-<port>, rfo-<pid>, vb6run-<pid>...), so two days of tests left DWM at 23,000
handles and 1 GB (a fresh DWM: about 2,000), and dragging a window took seconds; restarting DWM
cured it at once. Measured on BETA 1007, 2026-10-16:
| DWM handles | |
|---|---|
8 IDEs one after another, each from a new tb-launch.ps1, on one desktop another process held |
2082 → 2082 |
| releasing that desktop afterwards | +2, once |
| 8 IDEs, each on a desktop of its own (the old way) | +2 per launch, for good |
So a desktop is made once and held for as long as the broker runs, and handed from one launch to the next.
Ports collided. Each tool picked fixed ports (9333, 9346, 9440, 9480, 9560, 9620, 9660,
9700, 9720, 9800, 9910...) and only some claimed them (tb-ports.mjs's lock files, which nothing
outside those tools, launcher-re included, knows about). Worse, on the dev box the TCP dynamic
port range is 1024--65535 (the Windows default is 49152--65535; netsh int ipv4 show dynamicport tcp), so every port Windows picks for anyone --- each compiler's six listening sockets, the page
server's, every loopback WebSocket the IDE page opens, any program's outgoing connection --- can
land on a tool's fixed port. No lock among the tools could prevent that.
The fix for ports is mostly not to choose them: WebView2 given --remote-debugging-port=0
(after the IDE's own =<port> argument; the last one wins) binds a port Windows picks and writes
it to <user data folder>\EBWebView\DevToolsActivePort, as "48752\n/devtools/browser/<id>",
and attaching on that port works (BETA 1007, 2026-10-16). A broker port is for what must be
chosen before it is bound.
scripts/broker/src/ |
the program, an exported tree, laid out as scripts/imagestrip/ is |
scripts/broker/tbbroker.twinproj |
packed from src/; pack it again after a change: node scripts/impexp.mjs import scripts/broker/tbbroker.twinproj scripts/broker/src --overwrite |
%LOCALAPPDATA%\tbbroker\tbbroker.exe |
the installed broker, where every client looks for it; node scripts/tbbroker.mjs install builds and puts it there |
%LOCALAPPDATA%\tbbroker\tbbroker.log |
its log: each connection, lease, hold, end and desktop state, cut over to .log.1 at 1 MB |
scripts/lib/tb-broker.mjs |
the Node client |
test/broker.test.mjs |
its tests, skipped unless TBBROKER_EXE names a built broker |
It is a twinBASIC program because the owner chose so (2026-10-16): its most important clients
include tblaunch, a twinBASIC program, and it needs the Win32 calls (desktops, process handles,
overlapped pipes) that Node cannot make without a native addon. Its source carries nothing of
launcher-re's and nothing of the compiler's protocol, so it may live in this repository.
No gate builds it, as none builds scripts/imagestrip/: after a change to src/, pack it, run
node scripts/tbbroker.mjs install (which builds through tbbuild), and run the tests with
TBBROKER_EXE set to %LOCALAPPDATA%\tbbroker\tbbroker.exe. The build is the one IDE launch that
does not go through the broker, so building it never needs it.
tbbroker [start] [options] start the broker unless it runs already, and wait until it answers
tbbroker status [--pipe N] print the broker's state as one line of JSON
tbbroker stop [--pipe N] end the broker; its desktops go with it
tbbroker --serve [options] be the broker (what start runs)
--pipe <name> the pipe (default \\.\pipe\tbbroker-<USERNAME>)
--prefix <text> the desktops' names, <text>1 to <text><max> (default tbpool-)
--max <n> the most slots, and so desktops (default 32, at most 256)
--ports <a-b> the ports a lease may ask for, or none (default 48000-48199)
--log <file> the log, or none (default %LOCALAPPDATA%\tbbroker\tbbroker.log)
Exit codes: 0 done (start: it runs; status: it answered; stop: it has stopped), 1 failed (could
not be started, or did not answer), 2 a mistake in the command line, 3 --serve only: another
broker already serves the pipe, 4 status and stop: no broker runs.
It runs until logoff, or until it is stopped (owner). There is no idle exit: each desktop of
the pool is made once per logon. tbbroker stop ends it, and every lease with it; a running
broker goes on running the exe it was started from after an install, until it is stopped.
start starts tbbroker --serve as a process of its own and exits once the pipe answers, so the
broker is out of the starter's process tree (taskkill /T on a client never reaches it). It is
created with CREATE_BREAKAWAY_FROM_JOB, so it leaves a job that allows that (Node's does:
libuv's job has BREAKAWAY_OK; checked: a broker started by a Node client outlives it); in a job
that does not, it lives as long as the job. Its working folder is its own, never a worktree.
The pipe is made with FILE_FLAG_FIRST_PIPE_INSTANCE, so of two brokers started at once one
serves and the other exits 3; start then reports the one that serves.
The broker is a Windows-subsystem program with no window, so nothing flashes when a GUI client
starts it; status and stop write to the caller's standard output, or attach to its console.
Do not start it elevated: its desktops and pipe would carry an elevated DACL.
- Slot --- a number from 1 to
--max, unique among live leases. It is what a client names its own things by, in place of the port it used to: the IDE's temp folder and WebView2 profile, the ids of the projects it stages (laneProjectId). A slot is free again once its lease ends, and is then reused, so a client empties what it names by the slot before using it, aslaunchIdeempties its folders now. - Desktop --- slot n is always the desktop
<prefix>n(tbpool-3), in the window stationWinSta0. The broker makes it the first time a lease needs it and holds it open until it ends. A client starts its processes on it by name (STARTUPINFO.lpDesktop);CreateDesktopwith an existing name opens that desktop, sotb-launch.ps1works unchanged when given the name. - Lease --- a slot (with its desktop), and a port if one was asked for. It belongs to the connection that took it. It ends when that connection has closed or released it, and every process held for it has ended. The broker watches held processes by handle, so a reused pid cannot keep a lease alive.
- Dirty --- a desktop whose lease has ended while windows are still on it. It is not handed out until the windows have gone (checked four times a second). A broker that starts while a desktop of its pool still exists --- one that ended while something ran on it --- holds that desktop and treats it as dirty until it is empty, so it never hands out a desktop in use.
- Port --- the lowest port of
--portsthat no live lease has and that a bind on 127.0.0.1 withSO_EXCLUSIVEADDRUSEaccepts. It is checked when the lease is taken; nothing reserves it after that, so bind it soon. The default range, 48000--48199, is below Windows' default dynamic range, so on a machine left at the default Windows never picks those ports by itself. (The dev box's 50000--50059 is an administered exclusion --- a range reserved withnetsh int ipv4 add excludedportrange, which Windows never picks but a program may bind --- set up by some installed program; the broker leaves it alone.)
Transport. The named pipe \\.\pipe\tbbroker-<USERNAME> (the user name from the
environment), in byte mode, local clients only. Every message is one JSON object on one line,
UTF-8, ended by a line feed (a CR before it is allowed). Each request gets exactly one reply, in
order, except a waiting lease (below), whose reply comes when a slot comes free; match replies by
id. A request longer than 64 KB closes the connection. A client that has not read a reply within
5 s is cut off.
Requests have op, and may have id, any JSON value, which the reply gives back exactly as
it was written. Other members are as each op says; unknown members are ignored.
Replies are {"id":...,"ok":true,...} or {"id":...,"ok":false,"error":"<code>","message":"<text>"}.
A request that is not a JSON object is answered bad-request without an id.
| op | members | reply members |
|---|---|---|
hello |
client (optional): a name for this connection in status |
protocol (1), version, pid (the broker's), client (the caller's pid as the broker sees it) |
lease |
port (bool): a port as well; tag (string): what it is for, shown in status and the log; wait (bool): wait for a slot instead of failing full |
lease (its id), slot, desktop (its name), port (0 when not asked for) |
hold |
lease, pid |
none: the lease is now kept for as long as that process runs |
release |
lease |
none: this connection no longer keeps it; it ends once its held processes have |
status |
broker {pid, protocol, version, pipe, prefix, max, ports, started, log}, connections [{connection, pid, client}], slots [{slot, desktop, state: free, leased or dirty, lease, windows}] (only slots whose desktop exists), leases [{lease, slot, desktop, port, tag, client, connected, holds: [pid...], taken}], waiting (how many leases wait) |
|
stop |
none; then the broker ends, and its desktops and leases with it |
Error codes:
| code | when |
|---|---|
bad-request |
not a JSON object, no op, or an op without the members it needs |
unknown-op |
an op the broker does not know |
full |
lease without wait, and every slot is leased or dirty |
no-port |
lease with port, and no port of the range is free, or the broker has none (--ports none) |
desktop |
CreateDesktop failed; the message has the error |
no-lease |
hold or release of a lease there is not (or no longer) |
not-yours |
hold or release of a lease another connection took, or one this connection has released |
no-process |
hold of a pid that cannot be opened or has ended |
too-long |
a request over 64 KB; the connection is closed after it |
Waiting. lease with wait: true and no free slot is queued behind earlier waiters and
answered when a slot comes free, first come, first served; a waiter whose connection closes is
dropped. A lease with port that finds no port fails no-port at once rather than waiting.
- Connect once per process and keep the connection for as long as the process may hold a
lease: closing it ends every lease it has not handed to a held process. (
tb-broker.mjsunrefs the socket while no request is in flight, so it never keeps Node alive by itself.) - If the pipe cannot be opened (it does not exist, or is busy), run
tbbroker.exe start---TBBROKER_EXE, else%LOCALAPPDATA%\tbbroker\tbbroker.exe--- wait for it to exit (0 means the broker answers), and connect again: on ENOENT or EBUSY, again at a random 50 to 150 ms for up to 5 s, since clients that start the broker at once can find the pipe with no instance waiting for a moment (seen on 2026-10-22: four clients, all ENOENT). Sendhelloand checkprotocolis 1. - Before starting a program:
leasewith atagthat says what it is ("tbbuild 9333","lane help-2"). Start the program ondesktop, and name its folders and ids byslot. - Hand the lease to the process that lives as long as the program:
holdthe launcher's pid for a launch throughtb-launch.ps1in a job (the launcher lives exactly as long as the job, and so the IDE);holdthe program's own pid for one with no job (a--keepIDE), so that the lease outlives the tool that started it and ends when the IDE is closed. - When done,
releasethe lease, or simply close the connection or end the process. The desktop is handed out again once the job has ended and its windows have gone. - If the connection is lost (the broker was stopped or failed), the leases it held are void.
What runs on their desktops keeps them alive, and a new broker finds them dirty and waits for
them to empty, so nothing it hands out is in use; but a slot may be reused while the old
program still names folders by it. A client should treat a lost connection as the end of its
run's leases (
Broker.onLostin the Node client) and not start new programs under them. - For the IDE's DevTools port, do not lease one: start WebView2 with
--remote-debugging-port=0after the port argument and read the port from<WEBVIEW2_USER_DATA_FOLDER>\EBWebView\DevToolsActivePort(first line) once it appears. Lease a port only for a server that must be told its port before it binds it. - Never make a desktop outside the pool, and never close one of its desktops with
CloseDesktopon a handle the broker gave out --- it gives out names, not handles; a client's own handle fromOpenDesktop/CreateDesktopis the client's to close.
The Node client, scripts/lib/tb-broker.mjs:
import { connectBroker } from "./lib/tb-broker.mjs";
const broker = await connectBroker({ client: "tbbuild" }); // starts the broker if need be
const { lease, slot, desktop } = await broker.lease({ tag: `tbbuild ${project}` });
const run = await launchOnDesktop({ exe, desktop, ... }); // tb-launch.ps1 opens the desktop
await broker.hold(lease, run.launcher.pid); // the launcher lives as long as the job
// ... and at the end, broker.release(lease), or nothing: the lease ends with the launcher and
// this process's connection.connectBroker({pipe, client, start, exe, args}) resolves to a Broker with lease({port, tag, wait}), hold(lease, pid), release(lease), status(), stop(), close(), request(obj)
and onLost; a refused request rejects with a BrokerError whose code is the error code.
- One thread. Every pipe instance is tied to one I/O completion port; the loop waits on it for
a quarter of a second at a time and between packets looks at the held processes
(
WaitForSingleObject(h, 0)), the dirty desktops (EnumDesktopWindows) and the waiters. No thread of the twinBASIC runtime is involved (launcher-re found its thread safety undocumented), and there is no limit of 64 handles to wait on. - Reads go to the completion port; writes go to an event (its handle with the low bit set keeps them off the port) and are waited for, 5 s at most.
- The OVERLAPPED structures and read buffers are
HeapAlloc'd, so they never move while the system may write to them; a connection closed with a read outstanding is freed only when the cancelled read's packet arrives. - The broker never attaches a thread to a desktop of its pool, so it never stops one from being freed, and never closes one while it runs.
test/broker.test.mjs (14 tests, about 7 s, in test.bat) starts brokers on pipes and desktop
names of its own and checks: one broker per pipe, start/status/stop and their exit codes,
the client starting the broker; slots made once and handed out again, a lease ending with its
connection, a held process keeping it until it ends, release with a held process, the refusals,
full and waiting, ports skipping leased and listened-on ones; a desktop left with a window
(Notepad, through launchOnDesktop) staying dirty until the window goes; and a restarted broker
finding a desktop still in use and waiting for it. Run it with
TBBROKER_EXE=%LOCALAPPDATA%\tbbroker\tbbroker.exe node --test test/broker.test.mjs.
Every tool here takes its desktops and slots through scripts/lib/tb-ide.mjs, which holds one
broker connection per process:
leaseSlot({tag, port})returns{slot, desktop, port, handTo(pid), release()}; it waits while every slot is taken.handToholds the lease for a process and lets go of the connection's claim, so the lease ends with that process.launchOnDesktopwithout adesktopleases a slot of its own and hands it to the launcher (or, with no job, to the program); its result'sdesktopis whatdesktopWindowsandcaptureWindowtake while the program runs. With adesktop, the caller's slot is used.launchIdewith a caller'sslot(a lane, a tool's run) uses its desktop and names the IDE's temp folder and WebView2 profile by its number (tbbuild-tmp-<slot>,tbbuild-wv2-<slot>); without one it leases its own and hands it on aslaunchOnDesktopdoes (to the IDE itself underkeep). The DevTools port is 0 unless the caller gives one, and the result'sportis read fromEBWebView\DevToolsActivePort, accepted only when written since the launch.- Tools that name work folders lease first and key them by the slot (
tbbuild,tbrun,probe_build_twice,try_help_addin,svgshot/probe), or by their own pid when lanes come and go (lane-runner,check_examples,shoot_docs,sweep_attributes,package_tests, the package-test probes), removing the folders of runs whose process has ended. - The lane runners lease a lane's slot as the lane starts and hand it to the lane's process
(
TB_ADDIN_LANEcarriesslotanddesktop); aLanemade outside a runner leases its own on first use and releases it inclose(). - A port chosen in advance is a slot's
port: the help add-in's detached window (TB_DOCS_HELP_WINDOW_PORT, inhelp.test.mjsandshoot_docs's help setup). shoot_docs' web setup starts Chrome with port 0 too, and readsDevToolsActivePortfrom its profile. TB_BROKER=offdoes without the broker (a desktop of its own for each launch, namedtbnobroker-<pid>-<n>), fornode scripts/tbbroker.mjs install's own build only.
tblaunch's client is launcher-re's work (owner, 2026-10-16), from this file.