<feed xmlns='http://www.w3.org/2005/Atom'>
<title>slosh.git/src/server.c, branch docs-new</title>
<subtitle>an opinionated terminal multiplexer in C with programmable pane chrome</subtitle>
<id>https://git.sl0p.foo/slosh.git/atom/src/server.c?h=docs-new</id>
<link rel='self' href='https://git.sl0p.foo/slosh.git/atom/src/server.c?h=docs-new'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/'/>
<updated>2026-08-29T19:31:41Z</updated>
<entry>
<title>graphics: a frame the client never got must be said again</title>
<updated>2026-08-29T19:31:41Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-29T19:31:41Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=8a8b5e1c7acd269d4a639a40712412a84480ad18'/>
<id>urn:sha1:8a8b5e1c7acd269d4a639a40712412a84480ad18</id>
<content type='text'>
A pane image is re-emitted to the client as decoded RGBA, which for one
screenshot is ~4MB of base64 in a single indivisible message -- and
conn_send silently dropped anything that would overflow the 4MB outbox,
while push_graphics ignored the refusal and the model advanced anyway.
Everything stateful in that dropped frame was lost for good: the image
was marked sent (so its placements drew nothing, forever), and the
deletions rode out with it (so a placement the client had was never
revoked). Reported as screenshots in a recovered pi transcript parked
at a fixed spot on the screen, immune to scrolling and pane swaps --
those were placements whose deletion left with a dropped frame. The
1000x800 one could never be delivered at all: its transmission alone
was bigger than the cap.

So the frame is now transactional. Deletions queue and are repeated
every flush until gfx_commit() confirms the client heard them;
transmissions recorded in a frame are unwound by an undelivered commit
so they are sent again. A flush nobody commits counts as undelivered at
the next gfx_begin(), which is what the `graphics` control command is:
bytes rendered for a CLI, not the client. The server reports the
verdict and keeps the paint pending until it lands.

The outbox cap now bounds the backlog rather than backlog-plus-message,
so one big transmission on a drained queue always fits -- and that
surfaced a second way to lose: a queued image legitimately holds the
backlog over the cap until the client swallows it, and push_frame read
that as a dead client and closed the connection. The session
auto-detached every time a screenshot crossed the wire. A full outbox
is a client that is *behind*, not gone: push_frame now marks the screen
for a full repaint (the dropped diff means the client's cells are
unknown) and retries as POLLOUT drains the queue; a client that truly
left still shows up as a write error there.

The headless suite pins the owed-until-committed contract; the new
tests/live_graphics.py drives the real socket with a
bigger-than-the-cap image and a reader that stalls, and would have
caught both bugs: the flood must not detach, the transmission must
arrive whole, and a placement scrolled away must be deleted -- then
come back on scroll without a second copy of the pixels.
</content>
</entry>
<entry>
<title>windows watcher: rebuild the watch set instead of freezing it</title>
<updated>2026-08-28T13:10:39Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-08-28T13:10:39Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=b184ca357c6989b77171547071614a78b375cbcb'/>
<id>urn:sha1:b184ca357c6989b77171547071614a78b375cbcb</id>
<content type='text'>
watch_config() rebuilds the set of watched directories after every
reload, because a config that just changed may include different files
than it did a second ago. The inotify and kqueue branches unwatch and
re-add. The Windows branch did neither: unwatching was a comment saying
there was nothing to do, and sl_watch_add() appended to an array the
pump thread had already finished reading.

The pump captured `int n = w-&gt;ndirs` once, at the top, and armed one
ReadDirectoryChangesW per directory it found. Everything added after
that was stored and never watched. So an include added to a running
session loaded fine and then sat there: editing it reloaded nothing,
while editing the main config still worked, which is the shape of bug
that gets reported as "sometimes".

It was worse than deterministic. `w-&gt;ndirs` was written by the server
thread and read by the pump with nothing between them, so whether a
directory made it in depended on which won the start-up race --
_beginthreadex returns before the new thread runs, so the includes
present at startup usually did, and the ones added later never could.

And because nothing was ever closed, each reload re-added the same
directories: handles accumulated until the cap of 8, after which
sl_watch_add() failed quietly and no new directory could be watched at
all for the life of the session.

So the pump owns the handles and the server owns the wish. An
overlapped read can only be cancelled by the thread that issued it,
which settles where the boundary goes: the server writes a directory
list under a lock, bumps a generation and pokes an event; the pump
notices the generation moved, disarms what it had and arms what is
wanted now. Cancelling waits for the cancellation to land before the
slot is reused, because the OVERLAPPED and its buffer live in that
slot. Duplicate directories are dropped on the way in -- a config and
the theme beside it are usually in the same one -- and the compare is
case-insensitive, as the filesystem is.

sl_watch_close() also stopped every watcher in the process rather than
the one it was given, and freed nothing. It now finds its own, joins
the thread, and releases what that leaves behind.

Verified on the Windows box, with a fully backslashed SLOSH_CONFIG:
editing the config reloads; adding an include and then editing it
reloads; repointing the include at an unrelated directory reloads;
editing the file the include used to name does *not*, which is the
rebuild working rather than an append; and after twelve reloads in a
row it is still watching, where the old cap would have given up after
eight.
</content>
</entry>
<entry>
<title>path: a directory ends at either separator on Windows</title>
<updated>2026-08-28T13:09:18Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-08-28T13:04:28Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=6f8bdf838b82c0c3326993a741985840dc130af6'/>
<id>urn:sha1:6f8bdf838b82c0c3326993a741985840dc130af6</id>
<content type='text'>
Two places split a path by hand, both with strrchr(path, '/'), both
falling back to "." when there was none: include_path() in config.c and
watch_config() in server.c. On Windows "none" is the ordinary case.
`SLOSH_CONFIG=C:\Users\you\slosh\config.kdl` is how a person writes it,
and a path that has been near a native API comes back all backslashes
whatever it started as.

So an include resolved against "." -- `cannot open ./themes/t.kdl` --
and, worse and quietly, the config watcher watched "." too. Not the
config's directory: the current directory of whatever started the
session. Hot reload did not fail, it watched somewhere else and waited.
Saving your config did nothing at all, with no error to go looking for.

The default path escaped this by accident. slosh builds it as
`C:\Users\you/.config/slosh/config.kdl` -- mixed, because a literal
joined to $HOME always is -- and that one embedded '/' is what
strrchr found. It worked for the path slosh writes and not for the one
a user does.

Both callers now use path_dir(), which learns to stop at a backslash
too, and server.c takes path_base() for the other half rather than
keeping its own copy of the arithmetic. Windows-only: a backslash is a
legal character in a POSIX file name, and splitting on it there would
invent a directory out of a file called `a\b`.

path_dir() also keeps the separator when what is left would be a bare
drive, because `C:\config.kdl` lives in `C:\` and `C:` names the
current directory on drive C, which is a different place.

Verified on the Windows box with a fully backslashed SLOSH_CONFIG:
--check passes, the include loads, and editing the config reloads the
running session. Watching a second directory is still unreliable; that
is the watcher's own bug and the next commit.
</content>
</entry>
<entry>
<title>windows: a native port, on ConPTY and AF_UNIX</title>
<updated>2026-08-27T22:23:19Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-08-27T22:11:24Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=d1b5a64c3855b07074b3c958739df4e16f6084a5'/>
<id>urn:sha1:d1b5a64c3855b07074b3c958739df4e16f6084a5</id>
<content type='text'>
slosh builds and runs on Windows 10/11, x86-64 and ARM64, as one exe
with no runtime dependency beyond the OS: panes, splits, tabs,
detachable sessions, the config file and its live reload, the control
socket, the mouse.

The port is a compatibility layer rather than a fork. compat/win holds
stub &lt;poll.h&gt;, &lt;sys/socket.h&gt;, &lt;termios.h&gt; and friends that redirect to
include/compat_win.h, which is force-included ahead of every translation
unit -- so the existing sources keep their POSIX includes and their
POSIX call sites, and the platform differences are answered in one
place.

Two decisions carry most of it.

Everything the event loop waits on is a socket. On POSIX a pty master,
an inotify descriptor and a unix socket are all file descriptors, so one
poll() covers them; Windows has no such union, and WSAPoll waits on
SOCKETs and nothing else. Rather than rewrite server.c's loop around
WaitForMultipleObjects -- which caps at 64 objects and cannot express
POLLOUT -- each non-socket source gets a pump thread that copies it into
one end of a loopback socketpair. The loop is then the POSIX one
unchanged, and only what sits behind the descriptor differs.

Socket descriptors are tagged rather than guessed: a wrapped SOCKET
comes back as SL_SOCK_BASE + slot, so read, write, close and poll can
tell a socket from a CRT descriptor with certainty instead of inferring
it from the numeric range. That is why socket() and accept() are shimmed
too -- an untagged descriptor escaping into the loop is the one failure
mode this scheme has, and it is worth closing completely.

Three things bit hard enough to be worth naming.

A pseudoconsole does not stop the child inheriting your stdio.
CreateProcess copies the parent's standard handles into the child when
STARTF_USESTDHANDLES is absent, and those win. With slosh's own stdout
redirected -- a pipe under ssh, a file under --script, a log under the
daemon -- the shell wrote its banner into slosh's output instead of into
the pane, and the pane had a correct title (ConPTY emits that itself)
over an empty body. The three handles are cleared for the duration of
the call, leaving the child nothing to inherit.

A ConPTY never reports EOF. The output pipe belongs to the
pseudoconsole, not to the child, so it stays open after the child exits
and pane_pump()'s read() -- the entire mechanism by which a pane learns
its program is gone -- never returns 0. A pane whose shell had been told
`exit` sat there looking alive forever. So the exit is waited for on the
process handle, and ClosePseudoConsole is what then produces the EOF.
That third thread is also why the per-pty state is reference counted: a
pump blocked on a dead pipe must not be waited for, or pty_close would
hang the session on the one program that misbehaves, so whoever finishes
last frees.

cmd.exe does not use CommandLineToArgvW's quoting. A literal quote is
\" to an ordinary program and a backslash-then-end-of-quoted-section to
cmd, so building `cmd /c notepad "C:\...\config.kdl"` the first way
handed the editor \"C:\...\config.kdl\", backslashes and all, and
notepad answered "Not a valid file name" about a path that existed.
cmd /c now gets the rest of the line verbatim -- the caller composed a
complete command with its own quoting, exactly as it does for sh -c.

Paths are mixed, and only some programs mind. slosh joins with '/' while
the environment supplies '\', so the config landed at
C:\Users\you/.config/slosh/config.kdl. The CRT opens that without
complaint, which is why --check reported it fine, but a GUI file dialog
rejects it outright. Anything handed to another program now goes through
sl_path_native() first; the internal representation is left alone.

The rest is small. Panes run %ComSpec%, sessions live in
%LOCALAPPDATA%\slosh rather than $XDG_RUNTIME_DIR because a session
socket must not follow a roaming profile to another host, there is no
SIGWINCH so a resize is noticed by comparing the console size on a
bounded poll, no fork so the daemon is a fresh detached process that
reconstructs its arguments on the command line, and no process group on
a pty so the foreground program is the youngest descendant of the pane's
shell. C-a e prefers whichever console editor is actually installed --
notepad is a GUI program, so a pane running it draws nothing and looks
broken -- and says so when it has to fall back.

make -f Makefile.windows ARCH=x86_64 (or aarch64) cross-compiles from a
mac or Linux, and builds natively on the box with zig on PATH.
</content>
</entry>
<entry>
<title>selection: drag past an edge scrolls the viewport under it</title>
<updated>2026-08-27T13:09:02Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-27T13:09:02Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=2ee881452aa4b19701a071f0333a15265e3b314e'/>
<id>urn:sha1:2ee881452aa4b19701a071f0333a15265e3b314e</id>
<content type='text'>
Text taller than the viewport was not selectable in one gesture: the
drag stopped dead at the pane's border. Two faults, one visible and one
waiting. The visible one: DRAG_SELECT motion subtracted the content
origin in uint16_t, so a pointer above or left of the pane underflowed,
the grid lookup failed, and the selection froze. The waiting one: the
anchor was a pair of *viewport* coordinates re-resolved on every extend,
so the moment anything scrolled mid-drag the anchor would have drifted
with the viewport, re-anchoring to whatever slid under the press.

The anchor is now a tracked grid ref -- the thing lib-vt documents as
being for 'long-lived anchors such as selections' -- so it follows its
cell across scrolling, pruning and reflow, and only the near end of the
selection moves. If a program flooding output prunes the anchored row
out of scrollback entirely, the selection keeps what it had; there is no
cell left to anchor a new answer to.

Motion clamps into the content rect (a pointer past an edge is pointing
at the nearest cell), and holding past the top or bottom edge scrolls on
a clock rather than on motion, because a resting pointer sends no events
and 'keep going' is exactly what resting it there means. app_tick() does
the step, app_next_deadline_ms() wakes the poll loop for it -- the same
mechanism toasts and hover guides already use -- and both front ends
call the tick, so the behaviour exists under the server and the headless
harness alike. Rows per step is how far past the edge the pointer sits:
one row past creeps, further hurries, so the hand already dragging is
also the throttle. The metronome is select_scroll_ms, a knob so the test
can configure the clock down instead of sleeping.

The test drags to one row above the pane and simply holds: the viewport
walks to the top of scrollback (clamping makes the endpoint exact), the
copy starts at a line that was never on screen with the press, and still
ends at the pressed row -- that last check is the tracked anchor's.
</content>
</entry>
<entry>
<title>a client that stops reading must not stop the session</title>
<updated>2026-08-26T14:48:17Z</updated>
<author>
<name>Peter Geissler</name>
<email>peter@haxx.in</email>
</author>
<published>2026-08-26T14:44:33Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=9d5f9d2a1c3b8d0948a8efa8443a36847aa2107b'/>
<id>urn:sha1:9d5f9d2a1c3b8d0948a8efa8443a36847aa2107b</id>
<content type='text'>
Everything the server said went out through msg_send(), which loops on
sendmsg() until the kernel has taken all of it. That is a blocking write
to whoever is attached. A display whose reader has paused -- C-s at the
terminal, a stopped ssh, a client being swapped -- fills its socket
buffer, and then the *server* sits inside that loop: every other pane,
the config watcher, the control socket and every other connection stop
with it. A session that is supposed to outlive its client hangs on one.

macOS is where this showed up, because its socket buffers are a fraction
of Linux's 64K and it wedges within a screenful rather than after a few
hundred. `make test-live` could not get past the forty-connection probe
in test_session.py: the server was blocked writing to a pty nobody was
draining, so `slosh cmd panes` timed out and took the suite with it.

So connections are non-blocking and output is queued, flushed when
poll() says the socket will take more. The queue is capped: past 4MB we
are not buffering a slow reader any more, we are storing a screen nobody
is looking at, and the connection is dropped -- a display that comes
back reattaches and gets a full repaint, which is what happens after any
detach anyway.

Measured, with a pane running `yes` and a display that never reads:
before, the first control command hung forever; after, 40 of 40 are
answered, the session stays listed, and the server's RSS is flat.
</content>
</entry>
<entry>
<title>macos: the kernel still knows, it just spells /proc differently</title>
<updated>2026-08-26T14:11:42Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-26T14:11:42Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=087b2bd1d31dac24f8b0ffe79c33f4ebf1f60bd3'/>
<id>urn:sha1:087b2bd1d31dac24f8b0ffe79c33f4ebf1f60bd3</id>
<content type='text'>
Portability fixes contributed by our macOS developer, applied with one
hardening tweak (below).

Darwin has no /proc, so everything that read one gets the native answer:
live_cwd via proc_pidinfo(PROC_PIDVNODEPATHINFO), the foreground command
via sysctl(KERN_PROCARGS2) parsed back into the NUL-separated shape
/proc/&lt;pid&gt;/cmdline gives, and SLOSH_BIN via _NSGetExecutablePath plus
realpath. The pty gets its winsize set on the slave after opening it,
because Darwin attaches no line discipline to the master until then and
the earlier TIOCSWINSZ was silently dropped -- a pane born 0x0. The
shader repl binds tab in libedit's own words, since macOS ships Python's
readline as a libedit wrapper that accepts GNU syntax in silence.

Two finds are cross-platform bugs: a toast wider than the screen minus
the gap computed a negative x that uint16_t made ~65533, drawing the
longest messages -- the ones most worth reading -- off screen; now the
middle is elided (a path's tail is the half you needed) and x clamps.
And a pane label was 128 bytes, which a Homebrew interpreter's full path
exceeds before its arguments even start; dump-layout wrote the
truncation down and a restore ran it, so it is 1024 now.

Saving a layout learned cwd_as_named: the kernel answers with the
physical path, and on macOS /tmp is a symlink to /private/tmp, so a
layout that said cwd="/tmp" stopped round-tripping through a save.
If realpath(named) is where the pane still is, the named spelling wins.

Tests follow: realpath'd tmpdirs, the scroll test reads the shell's
first prompt instead of spelling out bash 5.3, and wrapped pane text is
joined before matching a long path.

One tweak on top of the contribution: the elision needs five columns
for a head, the dots and a tail -- below that `avail - 3` goes
negative, and negative as size_t is a pointer into the weeds; a
terminal that narrow gets a plain clip.
</content>
</entry>
<entry>
<title>slosh: the config watcher speaks kqueue on macOS</title>
<updated>2026-08-24T08:01:33Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-24T08:01:33Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=38a7506c3013fc6197cfbe657678a50ceb992bff'/>
<id>urn:sha1:38a7506c3013fc6197cfbe657678a50ceb992bff</id>
<content type='text'>
There is no inotify to port to, so the watcher grows a second native
backend: kqueue's EVFILT_VNODE, feeding the same pollable fd that
server_run() already watches. Vnode events are per-inode, so each
config file is watched twice -- via its directory (catching the
temp-file-then-rename most editors do, and files appearing for the
first time) and via the file itself (catching in-place rewrites).
Either event triggers the same debounced re-read as on Linux, which
re-registers the watches and so survives the rename swapping the
inode out from under us.
</content>
</entry>
<entry>
<title>slosh: greet an attach with the logo, wearing a random shader</title>
<updated>2026-08-19T12:10:52Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-19T12:10:52Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=1f5b55bc323e89399ec82eca392612ce2c0167a4'/>
<id>urn:sha1:1f5b55bc323e89399ec82eca392612ce2c0167a4</id>
<content type='text'>
logo.txt is the source of truth; the Makefile bakes it into
build/logo.h the way version.h is baked. When a client attaches
(MSG_HELLO turning a connection into the display), the logo appears
centered over everything for splash_ms (900, 0 for never), on a
cleared backdrop, in the accent -- and one of six effects runs over
its rect through the ordinary shader pass: a diagonal sheen, plasma,
rings, sparkle, a left-to-right reveal, a breathe. Which one is picked
by the splash's own timestamp, so every attach looks a little
different without anybody keeping a counter.

Every effect reads the clock, so the frame clock runs only while the
splash is up (note_animation, same as any animated chain), and `since`
in a splash context is milliseconds since it appeared, so one-shot
effects sweep exactly once. Any key or click ends it and still lands
where it was aimed: a greeting is not a modal. A screen the box does
not fit on gets no greeting rather than a cropped one.

Headless and scripted sessions never attach, so the whole fast suite
never sees it; {"cmd":"splash"} triggers it on demand, which is what
makes it testable from a scripted session -- and lets you replay the
greeting whenever you like.
</content>
</entry>
<entry>
<title>slosh: rename the project from sl0ppty</title>
<updated>2026-08-18T19:26:00Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-18T19:26:00Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/slosh.git/commit/?id=b9e53cace4eb75263077df8623b011e66f5f2241'/>
<id>urn:sha1:b9e53cace4eb75263077df8623b011e66f5f2241</id>
<content type='text'>
sl0p + pty was error-prone to type and the zero made it worse; slosh
(slop + sh) is one syllable, impossible to typo, and slosh.foo is ours.

Mechanical rename everywhere it appears: binary, env vars (SLOSH,
SLOSH_SESSION, SLOSH_BIN, SLOSH_CONFIG), socket dir (/tmp/slosh-&lt;uid&gt;,
$XDG_RUNTIME_DIR/slosh), config path (~/.config/slosh), header guards,
src/sl0ppty.h -&gt; src/slosh.h, contrib/sl0ppty-dev -&gt; contrib/slosh-dev,
the driving-slosh skill, docs and tests. The sl0ppi fork references and
the sl0p theme stay: one is history, the other is the brand.

The rename surfaced a latent bug in test_osc5577: the hello-reply
answer always appeared on screen twice (ECHOCTL echo plus cat -v), but
a seven-character implementation name happened to wrap the second copy
across the 60-column boundary, so counting substrings in wrapped text
found one and passed for the wrong reason. A five-character name moved
the wrap and exposed it. The loop guard itself is correct -- with echo
off before the request there is exactly one reply -- so the test now
counts on de-wrapped text and tolerates the pane's own echo.
</content>
</entry>
</feed>
