This is an automated email from the git hooks/post-receive script.
git pushed a commit to branch tymux
in repository terminology.
View the commit online.
commit 4e0a1f8f02b294e63fa94b4c603276e8da9e7d24
Author: [email protected] <[email protected]>
AuthorDate: Sun Mar 22 13:06:03 2026 -0600
feat: implement client-side backlog buffer access for tymux
The tymux client now reads scrollback directly from shared-memory
backlog buffers via the backlog_get callback. On attach the head
buffer is mmap'd immediately; older buffers are fetched lazily on
first scroll access. The NOTIFY handler detects new and evicted
buffers, keeping the client's view synchronized with the daemon.
This replaces the removed heuristic with reliable, zero-copy
scrollback that includes full pre-attachment history.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---
src/bin/termtymux.c | 402 ++++++++++++++++++++++++++++++++++++++++++++++++++--
src/bin/termtymux.h | 16 ++-
2 files changed, 404 insertions(+), 14 deletions(-)
diff --git a/src/bin/termtymux.c b/src/bin/termtymux.c
index bb2ebc49..8861d1c5 100644
--- a/src/bin/termtymux.c
+++ b/src/bin/termtymux.c
@@ -19,6 +19,264 @@
#include <stdatomic.h>
#include <time.h>
+/* ── backlog buffer helpers ─────────────────────────────────────────── */
+
+/*
+ * Ensure that the backlog_bufs array has at least (ts->backlog_buf_count + 1)
+ * slots. Returns EINA_TRUE on success, EINA_FALSE on allocation failure.
+ */
+static Eina_Bool
+_backlog_bufs_grow(TermTymux *ts)
+{
+ int new_alloc;
+ TermBacklogBuf *nb;
+
+ if (ts->backlog_buf_count < ts->backlog_buf_alloc)
+ return EINA_TRUE;
+
+ new_alloc = ts->backlog_buf_alloc ? ts->backlog_buf_alloc * 2 : 4;
+ nb = realloc(ts->backlog_bufs,
+ (size_t)new_alloc * sizeof(TermBacklogBuf));
+ if (!nb)
+ {
+ ERR("termtymux: realloc backlog_bufs failed");
+ return EINA_FALSE;
+ }
+ ts->backlog_bufs = nb;
+ ts->backlog_buf_alloc = new_alloc;
+ return EINA_TRUE;
+}
+
+/*
+ * Add a new entry at the tail of ts->backlog_bufs. The new entry takes
+ * ownership of the already-mmap'd (hdr, size) pair and records buf_id.
+ * Returns EINA_TRUE on success.
+ */
+static Eina_Bool
+_backlog_buf_push(TermTymux *ts,
+ TermBacklogBufHeader *hdr, size_t size, uint32_t buf_id)
+{
+ TermBacklogBuf *slot;
+
+ if (!_backlog_bufs_grow(ts))
+ return EINA_FALSE;
+
+ slot = &ts->backlog_bufs[ts->backlog_buf_count];
+ slot->hdr = hdr;
+ slot->size = size;
+ slot->buf_id = buf_id;
+ ts->backlog_buf_count++;
+ return EINA_TRUE;
+}
+
+/*
+ * Evict entries from the front of ts->backlog_bufs whose buf_id is
+ * strictly less than new_oldest_id. Munmaps each evicted buffer.
+ */
+static void
+_backlog_bufs_evict_old(TermTymux *ts, uint32_t new_oldest_id)
+{
+ int evict = 0;
+
+ while (evict < ts->backlog_buf_count &&
+ ts->backlog_bufs[evict].buf_id < new_oldest_id)
+ {
+ TermBacklogBuf *b = &ts->backlog_bufs[evict];
+
+ if (b->hdr)
+ {
+ munmap((void *)b->hdr, b->size);
+ b->hdr = NULL;
+ }
+ evict++;
+ }
+
+ if (evict > 0)
+ {
+ int remaining = ts->backlog_buf_count - evict;
+
+ if (remaining > 0)
+ memmove(ts->backlog_bufs, ts->backlog_bufs + evict,
+ (size_t)remaining * sizeof(TermBacklogBuf));
+ ts->backlog_buf_count = remaining;
+ }
+}
+
+/*
+ * Request and mmap a backlog buffer from the daemon, then push it into
+ * ts->backlog_bufs. Returns EINA_TRUE on success.
+ *
+ * NOTE: this temporarily clears O_NONBLOCK on the socket (via
+ * termtymux_request_backlog_buf) and must only be called from the main
+ * loop (not from an Ecore fd-handler that is itself running inside a
+ * non-blocking recv).
+ */
+static Eina_Bool
+_backlog_buf_fetch(TermTymux *ts, uint32_t buf_id)
+{
+ int fd = -1;
+ uint32_t cols = 0, num_lines = 0, max_lines = 0;
+ size_t map_size;
+ void *ptr;
+ TermBacklogBufHeader *hdr;
+
+ if (termtymux_request_backlog_buf(ts, buf_id,
+ &fd, &cols,
+ &num_lines, &max_lines) < 0)
+ return EINA_FALSE;
+
+ if (cols == 0 || cols > TERMSRV_MAX_COLS)
+ {
+ ERR("termtymux: backlog buf %u has invalid cols %u", buf_id, cols);
+ close(fd);
+ return EINA_FALSE;
+ }
+
+ map_size = TERMSRV_BACKLOG_TOTAL_SIZE(cols);
+ ptr = mmap(NULL, map_size, PROT_READ, MAP_SHARED, fd, 0);
+ close(fd);
+ if (ptr == MAP_FAILED)
+ {
+ ERR("termtymux: mmap backlog buf %u: %s", buf_id, strerror(errno));
+ return EINA_FALSE;
+ }
+
+ hdr = ptr;
+ if (hdr->magic != TERMSRV_BACKLOG_MAGIC ||
+ hdr->version != TERMSRV_BACKLOG_VERSION)
+ {
+ WRN("termtymux: backlog buf %u magic/version mismatch", buf_id);
+ munmap(ptr, map_size);
+ return EINA_FALSE;
+ }
+
+ if (!_backlog_buf_push(ts, hdr, map_size, buf_id))
+ {
+ munmap(ptr, map_size);
+ return EINA_FALSE;
+ }
+
+ return EINA_TRUE;
+}
+
+/*
+ * Recalculate and update ty->backsize and ty->backlog_beacon so that
+ * termpty_backscroll_adjust() clamps scrollback correctly.
+ *
+ * The beacon approach: set screen_y = total_lines so the early-return
+ * path (*scroll < screen_y) in termpty_backscroll_adjust handles clamping
+ * without walking ty->back (which is NULL for shadow ptys).
+ */
+static void
+_backlog_update_backsize(TermTymux *ts)
+{
+ Termpty *ty = ts->shadow_pty;
+ size_t total = 0;
+ int i;
+
+ for (i = 0; i < ts->backlog_buf_count; i++)
+ {
+ TermBacklogBuf *b = &ts->backlog_bufs[i];
+
+ if (b->hdr)
+ {
+ /* head buffer: num_lines is live (re-read atomically) */
+ total += b->hdr->num_lines;
+ }
+ else
+ {
+ /* unmapped historical buffer: assume it was filled */
+ total += TERMSRV_BACKLOG_MAX_LINES;
+ }
+ }
+
+ ty->backsize = total;
+ ty->backlog_beacon.screen_y = (int)total;
+ ty->backlog_beacon.backlog_y = (int)total;
+}
+
+/*
+ * backlog_get callback installed on the shadow Termpty.
+ *
+ * y is negative: -1 = most recent backlog line, -2 = second most recent, …
+ *
+ * We walk the buffer chain newest-to-oldest, counting lines until we find
+ * the buffer that owns offset = -y. For lines that land in a buffer that
+ * has not been mmap'd yet we request it on the fly.
+ *
+ * Returns a pointer into the mmap'd cells on success; NULL if y is out
+ * of range or a fetch fails.
+ */
+static Termcell *
+_backlog_get_cb(void *data, int y, ssize_t *wret)
+{
+ TermTymux *ts = data;
+ int offset; /* 1 = most-recent, 2 = second-most-recent, … */
+ int i;
+ size_t cumulative = 0;
+
+ if (!ts || !wret || y >= 0)
+ return NULL;
+
+ offset = -y; /* convert: y=-1 → offset=1, y=-2 → offset=2, … */
+
+ /* Walk newest-to-oldest (highest index → 0) */
+ for (i = ts->backlog_buf_count - 1; i >= 0; i--)
+ {
+ TermBacklogBuf *b = &ts->backlog_bufs[i];
+ uint32_t n_lines;
+ uint32_t local_offset;
+ uint32_t line_idx;
+ TermBacklogBufHeader *hdr;
+
+ /* Determine how many lines this buffer holds.
+ * The head (newest, last in array) may be partially filled;
+ * older buffers are assumed full if not yet mmap'd. */
+ if (b->hdr)
+ n_lines = b->hdr->num_lines; /* live read from mmap */
+ else if (i == ts->backlog_buf_count - 1)
+ n_lines = 0; /* head buf should always be mmap'd */
+ else
+ n_lines = TERMSRV_BACKLOG_MAX_LINES; /* assume full */
+
+ if (n_lines == 0)
+ continue;
+
+ cumulative += n_lines;
+
+ if ((size_t)offset > cumulative)
+ continue; /* not in this buffer, keep walking */
+
+ /* offset lands in this buffer */
+ /* local_offset: 1 = newest line in this buffer */
+ local_offset = (uint32_t)(offset - (cumulative - n_lines));
+
+ /* Ensure buffer is mmap'd */
+ if (!b->hdr)
+ {
+ if (!_backlog_buf_fetch(ts, b->buf_id))
+ return NULL;
+ /* _backlog_buf_fetch appended to end; our 'b' pointer is now
+ * stale only if the realloc moved the array. Re-look-up. */
+ b = &ts->backlog_bufs[i];
+ }
+ hdr = b->hdr;
+ if (!hdr || hdr->num_lines == 0)
+ return NULL;
+
+ /* line_idx: 0 = oldest, num_lines-1 = newest */
+ /* local_offset=1 → newest = num_lines-1 */
+ if (local_offset == 0 || local_offset > hdr->num_lines)
+ return NULL;
+ line_idx = hdr->num_lines - local_offset;
+
+ *wret = (ssize_t)hdr->line_widths[line_idx];
+ return TERMSRV_BACKLOG_LINE(hdr, line_idx);
+ }
+
+ return NULL; /* offset beyond all available lines */
+}
+
/* ── write_cb: forward input bytes to daemon via TSRV_MSG_INPUT ─────── */
static void
@@ -228,6 +486,58 @@ _cb_tymux_data(void *data, Ecore_Fd_Handler *handler EINA_UNUSED)
{
uint64_t seq;
+ /* Parse extended backlog fields if the payload is large enough */
+ if (payload && size >= sizeof(TermSrvMsgNotify))
+ {
+ TermSrvMsgNotify *n = payload;
+ uint32_t new_count = n->backlog_buf_count;
+ uint32_t new_oldest = n->oldest_buf_id;
+ /* Compute the next buffer id we haven't fetched yet BEFORE
+ * evicting old entries, so the formula stays correct even
+ * when oldest_buf_id advances. */
+ uint32_t next_new_id = ts->oldest_buf_id
+ + ts->last_backlog_buf_count;
+
+ /* Evict buffers the daemon has already freed */
+ if (new_oldest > ts->oldest_buf_id)
+ {
+ _backlog_bufs_evict_old(ts, new_oldest);
+ ts->oldest_buf_id = new_oldest;
+ }
+
+ /* Fetch new head buffer(s) produced since the last NOTIFY.
+ * new_head = oldest + count - 1; we want every id from
+ * next_new_id through new_head. */
+ if (new_count > 0)
+ {
+ uint32_t new_head = new_oldest + new_count - 1;
+ uint32_t id;
+
+ for (id = next_new_id; id <= new_head; id++)
+ {
+ /* Don't re-fetch an id already tracked */
+ Eina_Bool already = EINA_FALSE;
+ int k;
+
+ for (k = 0; k < ts->backlog_buf_count; k++)
+ {
+ if (ts->backlog_bufs[k].buf_id == id)
+ {
+ already = EINA_TRUE;
+ break;
+ }
+ }
+ if (!already)
+ _backlog_buf_fetch(ts, id);
+ }
+ }
+
+ ts->last_backlog_buf_count = new_count;
+
+ /* Recalculate total scrollback lines visible to renderer */
+ _backlog_update_backsize(ts);
+ }
+
/* Read write_seq from shm with acquire semantics */
seq = atomic_load_explicit(&ts->shm->write_seq,
memory_order_acquire);
@@ -470,13 +780,10 @@ termtymux_attach(const char *tymux_name,
goto fail;
}
- /* Backlog fd: close it for now — client buffer management (Task 8)
- * will mmap it once that infrastructure is in place. */
- if (backlog_fd >= 0)
- {
- close(backlog_fd);
- backlog_fd = -1;
- }
+ /* backlog_fd is intentionally NOT closed here. It is deferred until
+ * after ts is allocated so we can mmap the head buffer directly into
+ * ts->backlog_bufs. The fd will be closed (and the mapping set up)
+ * in the post-allocation block below. */
/* Map shared memory read-only */
shm = mmap(NULL, TERMSRV_SHM_TOTAL_SIZE,
@@ -519,9 +826,59 @@ termtymux_attach(const char *tymux_name,
ts->shadow_pty = ty;
ts->last_seq = 0;
- /* Now that ts is ready, wire the write_cb */
- ty->write_cb.func = _tymux_write_cb;
- ty->write_cb.data = ""
+ ts->oldest_buf_id = oldest_buf_id;
+ ts->last_backlog_buf_count = backlog_buf_count;
+
+ /* mmap the head backlog buffer received in ATTACHED.
+ * Map at maximum size so we don't need to do a two-stage map just to
+ * read the cols field. Excess virtual pages have no physical cost. */
+ if (backlog_fd >= 0 && backlog_buf_count > 0)
+ {
+ size_t map_size = TERMSRV_BACKLOG_TOTAL_SIZE(TERMSRV_MAX_COLS);
+ void *ptr;
+
+ ptr = mmap(NULL, map_size, PROT_READ, MAP_SHARED, backlog_fd, 0);
+ close(backlog_fd);
+ backlog_fd = -1;
+
+ if (ptr != MAP_FAILED)
+ {
+ TermBacklogBufHeader *hdr = ptr;
+ uint32_t head_id;
+
+ if (hdr->magic == TERMSRV_BACKLOG_MAGIC &&
+ hdr->version == TERMSRV_BACKLOG_VERSION)
+ {
+ head_id = oldest_buf_id + backlog_buf_count - 1;
+ if (!_backlog_buf_push(ts, hdr, map_size, head_id))
+ {
+ WRN("termtymux: failed to push head backlog buf");
+ munmap(ptr, map_size);
+ }
+ }
+ else
+ {
+ WRN("termtymux: head backlog buf magic/version mismatch");
+ munmap(ptr, map_size);
+ }
+ }
+ else
+ WRN("termtymux: mmap head backlog buf: %s", strerror(errno));
+ }
+ else if (backlog_fd >= 0)
+ {
+ close(backlog_fd);
+ backlog_fd = -1;
+ }
+
+ /* Update backsize so termpty_backscroll_adjust clamps correctly */
+ _backlog_update_backsize(ts);
+
+ /* Now that ts is ready, wire the write_cb and backlog_get */
+ ty->write_cb.func = _tymux_write_cb;
+ ty->write_cb.data = ""
+ ty->backlog_get.func = _backlog_get_cb;
+ ty->backlog_get.data = ""
/* Initial sync from shm */
_sync_shadow(ts);
@@ -616,6 +973,25 @@ termtymux_free(TermTymux *ts)
_shadow_pty_free(ts->shadow_pty);
ts->shadow_pty = NULL;
+ /* Munmap and free all tracked backlog buffers */
+ if (ts->backlog_bufs)
+ {
+ int i;
+
+ for (i = 0; i < ts->backlog_buf_count; i++)
+ {
+ TermBacklogBuf *b = &ts->backlog_bufs[i];
+
+ if (b->hdr)
+ {
+ munmap((void *)b->hdr, b->size);
+ b->hdr = NULL;
+ }
+ }
+ free(ts->backlog_bufs);
+ ts->backlog_bufs = NULL;
+ }
+
free(ts);
}
@@ -636,7 +1012,7 @@ termtymux_resize(TermTymux *ts, int cols, int rows)
}
int
-termsession_request_backlog_buf(TermTymux *ts, uint32_t buf_id,
+termtymux_request_backlog_buf(TermTymux *ts, uint32_t buf_id,
int *fd_out, uint32_t *cols_out,
uint32_t *num_lines_out,
uint32_t *max_lines_out)
@@ -658,7 +1034,7 @@ termsession_request_backlog_buf(TermTymux *ts, uint32_t buf_id,
if (termsrv_msg_send(ts->sock_fd, TSRV_MSG_BACKLOG_BUF_REQ,
&req, sizeof(req)) < 0)
{
- ERR("termsession: send BACKLOG_BUF_REQ: %s", strerror(errno));
+ ERR("termtymux: send BACKLOG_BUF_REQ: %s", strerror(errno));
return -1;
}
@@ -675,7 +1051,7 @@ termsession_request_backlog_buf(TermTymux *ts, uint32_t buf_id,
&buf_id, cols_out,
num_lines_out, max_lines_out);
if (ret < 0)
- ERR("termsession: recv BACKLOG_BUF failed");
+ ERR("termtymux: recv BACKLOG_BUF failed");
/* Restore non-blocking mode regardless of recv outcome */
if (flags >= 0)
diff --git a/src/bin/termtymux.h b/src/bin/termtymux.h
index 2862c1e4..04f4c904 100644
--- a/src/bin/termtymux.h
+++ b/src/bin/termtymux.h
@@ -14,6 +14,13 @@
typedef struct _TermTymux TermTymux;
+/* One tracked backlog buffer (mmap'd read-only from daemon). */
+typedef struct {
+ TermBacklogBufHeader *hdr; /* mmap'd read-only, or NULL if not yet fetched */
+ size_t size; /* total mmap size */
+ uint32_t buf_id; /* global buffer id assigned by daemon */
+} TermBacklogBuf;
+
struct _TermTymux {
int sock_fd;
TermShmHeader *shm;
@@ -23,6 +30,13 @@ struct _TermTymux {
Ecore_Fd_Handler *handler;
+ /* Backlog buffer tracking (ordered oldest-to-newest) */
+ TermBacklogBuf *backlog_bufs; /* heap array of tracked buffers */
+ int backlog_buf_count; /* live entries in array */
+ int backlog_buf_alloc; /* allocated slots */
+ uint32_t oldest_buf_id; /* first valid buffer id from daemon */
+ uint32_t last_backlog_buf_count; /* daemon's buf_count at last NOTIFY */
+
/* Callbacks — set these after termtymux_attach() returns,
* before the Ecore main loop fires. */
struct {
@@ -58,7 +72,7 @@ void termtymux_resize(TermTymux *ts, int cols, int rows);
* *cols_out, *num_lines_out and *max_lines_out describe the buffer.
* Returns 0 on success, -1 on failure.
*/
-int termsession_request_backlog_buf(TermTymux *ts, uint32_t buf_id,
+int termtymux_request_backlog_buf(TermTymux *ts, uint32_t buf_id,
int *fd_out, uint32_t *cols_out,
uint32_t *num_lines_out,
uint32_t *max_lines_out);
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.