Skip to content

fix(history): pass the history and unread-marker SQL on stdin (#777) - #899

Merged
fujibee merged 2 commits into
fujibee:mainfrom
joelmitz:fix/777-history-stdin
Aug 20, 2026
Merged

fix(history): pass the history and unread-marker SQL on stdin (#777)#899
fujibee merged 2 commits into
fujibee:mainfrom
joelmitz:fix/777-history-stdin

Conversation

@joelmitz

Copy link
Copy Markdown
Contributor

Fixes the two history.sh call sites listed in #777 (history.sh:47 and history.sh:65 in
the issue's numbering) by passing the SQL on stdin instead of as one argv element.

Why these two first

They are the pair that makes history unusable rather than merely slow, and one of them is
not bounded by anything the operator can control:

  • :64-65 (unread marker) — builds json_each('<the recipient's entire unread backlog>'). This set is independent of the display limit, so history.sh <team> "" 3
    fails exactly like history.sh <team>. From the operator's side, asking for fewer messages
    does not help at any value.
  • :41 (display slice) — same construction over the displayed rows. Bounded by the
    limit, but a single long body can carry it past the ceiling on its own, so it is fixed in
    the same commit.

Measured on Linux, not only Windows

This issue is scoped in its title to Windows/Git Bash. It reproduces on Linux, and the
headroom there is much smaller than getconf ARG_MAX suggests — the limit that fails is the
per-argument one:

limit value here exceeded how established
ARG_MAX (argv+envp total) 2,097,152 no getconf ARG_MAX
MAX_ARG_STRLEN (one argument) 131,072 (32 * PAGE_SIZE) yes measured (below)

Boundary, measured with a plain external binary:

$ for n in 131071 131072; do
    arg=$(head -c $n /dev/zero | tr '\0' 'a')
    /bin/echo "$arg" >/dev/null 2>&1 && echo "$n OK" || echo "$n E2BIG"
  done
131071 OK
131072 E2BIG

On a live team (2,079+ messages, ~31 MB store) the unread statement measures 125,945
bytes — 96% of that ceiling
, i.e. about 5 KB of headroom remains. One sufficiently large
message, or a handful of ordinary ones, takes it over, and history then stops working
entirely. So on Linux this is reached at roughly 128 KB of
accumulated unread payload for one recipient, which is a normal team where a participant has
not read for a while — not an unusually large one.

Before/after in the same installation, same team:

unpatched   exit 126   lib/storage.sh: line 237: sqlite3: Argument list too long
patched     exit 0     prints the requested rows

Approach

Mirrors what drivers/storage/sqlite-sync.sh:1082 already does for the sync-apply batch:
write the statement to a temp file, feed it to sqlite3 on stdin. printf is a bash
builtin, so passing the large value through it does not exec and cannot hit the limit.
Temp files are removed via trap ... EXIT HUP INT TERM, following the idiom already used
in export.sh:90, so a failing sqlite3 does not leave one behind.

No behaviour change otherwise — same SQL, same output, same escaping (sed "s/'/''/g" is
kept and applied identically).

Tests

Existing suites, unmodified, on Linux with bats 1.13.0:

tests/test_messaging.bats
tests/test_storage.bats
tests/test_storage_contract.bats
tests/test_bin_agmsg.bats
tests/test_binding_mode.bats
tests/test_migrate_team_store.bats
tests/test_legacy_mirror.bats
-> 133 tests, 0 failures

Those are the suites that exercise history.sh (grep -l history.sh tests/*.bats).

Which numbers are measured, which are not

  • measured: the 131,071/131,072 boundary; the 125,945-byte statement on a live team;
    the unpatched-vs-patched exit codes; the bats results.
  • arithmetic, not measured: nothing in this PR body. (An estimated ~420-message
    threshold appears in the issue thread for the pull path; it is not used here.)
  • untested: the --limit ~200 idea floated in the issue thread for remote pull is a
    candidate to test, not a recommendation, and is not part of this change.

Note on the commit message: an earlier revision of this branch attributed the failure to
ARG_MAX. That was wrong and is corrected both in the current commit message and in
the issue thread.

Not covered here

The other five call sites named in #777 (check-inbox.sh:241, inbox.sh:51,
remote.sh:786, watch.sh:704, drivers/types/codex/watch-once.sh:131) and the pull-apply
site reported separately in the issue thread (sqlite-sync.sh:1094-1112) are untouched. Happy
to extend this PR to any of them if you would rather land them together — I kept the scope to
the two that were blocking day-to-day history use here.

Disclosure

Investigated and drafted by Claude via Claude Code, prepared with the user's authorization,
following the same convention as the rest of #777.

…e#777)

history.sh builds two statements by interpolating a JSON array into the SQL text and
passing the whole string as one argv element:

  :41  the display slice, via json_each('<HIST_JSONL as a JSON array>')
  :64  the recipient's unread backlog, via json_each('<unread list as a JSON array>')

Both eventually exceed the per-argument length ceiling. Measured on Linux, not only
Windows: the limit that fails is MAX_ARG_STRLEN (one argument), which is 32 * PAGE_SIZE =
131,072 here -- NOT ARG_MAX (argv+envp total), which is 2,097,152 and is never approached.
Boundary measured with a plain external binary: 131,071 OK, 131,072 E2BIG.

On a live team the unread statement measures 125,945 bytes, i.e. 96% of that ceiling, so
one more ordinary message tips it over. Past it, history exits 126 with
"sqlite3: Argument list too long" from lib/storage.sh:237.

The unread one (:64) bites first and is the more surprising of the two, because it is NOT
bounded by the display limit: `history.sh <team> "" 3` fails the same way, since
storage_list_unread returns the recipient's whole backlog regardless of how many rows the
caller asked to display. From the operator's side, asking for fewer messages does not help
at any value.

Write each statement to a temp file and feed it to sqlite3 on stdin, which is what
drivers/storage/sqlite-sync.sh:1082 already does for the sync-apply batch. printf is a
bash builtin, so passing the large value through it does not exec and cannot hit the
limit. Temp files are removed via trap on EXIT HUP INT TERM, following the idiom already
used in export.sh:90.

Verified on a team where the unpatched script exits 126: patched exits 0 and prints the
requested rows. Existing bats suites that exercise history.sh (test_messaging,
test_storage, test_storage_contract, test_bin_agmsg, test_binding_mode,
test_migrate_team_store, test_legacy_mirror) pass: 133 tests, 0 failures.
@fujibee

fujibee commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Thank you for this — and for splitting it the way you did. Naming the unread marker as the
one an operator cannot work around, because it is built from the whole backlog rather than
the display slice, is the distinction that decides which of the two matters. history.sh <team> "" 3 failing exactly like history.sh <team> is the sentence that makes it obvious.

The code path has no blocking finding. One thing to fix before this lands, and it is only in
a comment:

The comment in the unread branch names the wrong limit. It says the statement "eventually
exceeds ARG_MAX" and that "ARG_MAX 2,097,152 was not enough for a 2,079-message team". Your
own measurement puts the failing statement at 125,945 bytes, which is comfortably inside
ARG_MAX — what it exceeds is MAX_ARG_STRLEN, the per-argument ceiling, 131,072 bytes on
Linux. Those are two different limits: one bounds the total of argv plus environment, the
other bounds any single element of it.

Your PR body and commit message already say this correctly. It is the comment that kept the
earlier reading, and a comment is what the next person will believe.

Worth stating precisely there, because the two limits behave differently and the difference
is the whole shape of the bug: splitting one long statement into several shorter arguments
would satisfy MAX_ARG_STRLEN while leaving ARG_MAX untouched, and someone reading
"ARG_MAX" may reach for the wrong repair.

For what it is worth, the same class was just fixed on the sync side and landed in main as
#895 — pull, push acknowledgements, and roster, all moved to stdin. The two do not overlap in
any file. On Windows the ceiling that bites is smaller again: a command line is capped near
32,767 characters, which we measured at 413 message ids passing and 418 failing.

The comment recorded ARG_MAX. The measurement in this PR's own body puts the
failing statement at 125,945 bytes, which is well inside ARG_MAX (2,097,152 on
the machine measured) -- what it exceeds is MAX_ARG_STRLEN, the ceiling on a
single argument, 131,072 bytes on Linux.

The two are not interchangeable and the difference decides the repair: splitting
one long statement into several shorter arguments satisfies MAX_ARG_STRLEN while
leaving ARG_MAX untouched. A reader who has the wrong one in mind reaches for the
wrong fix.

Also worth having in the comment: MAX_ARG_STRLEN has no getconf key, so the limit
that bites is the one the tools will not show you. That is why the wrong one is
easy to reach for.

The body and the commit message already had this right; only the comment kept the
earlier reading. Author's commit is unchanged.
@fujibee

fujibee commented Aug 19, 2026

Copy link
Copy Markdown
Owner

I pushed the comment correction to your branch rather than asking you to round-trip for one
paragraph — maintainerCanModify was on, so the change is one commit on top of yours.
Your commit is untouched, and this stays your pull request. If you would rather have
written it yourself, say so and I will drop it.

What the added commit changes, and nothing else:

- exceeds ARG_MAX (measured on Linux: 2,097,152 was not enough for a
  2,079-message team)
+ exceeds the ceiling on a SINGLE argument -- on Linux MAX_ARG_STRLEN,
+ 131,072 bytes. The failing statement was 125,945 bytes, nowhere near
+ ARG_MAX, because ARG_MAX bounds argv plus environment in total.

It also picks up something worth having in the file: MAX_ARG_STRLEN has no getconf
key.
getconf ARG_MAX answers; getconf MAX_ARG_STRLEN says there is no such parameter.
So the limit that actually stops you is the one the tools will not show, and the one they
will show is the one that had room to spare. That is a good reason to reach for the wrong
name, and a good reason to write the right one down where the next person will read it.

Same on the other platforms, for what it is worth: the Windows ceiling is the documented
CreateProcess command-line maximum near 32,767 characters, which getconf does not answer
either. Two of the three limits in play are invisible to the tools.

CI should re-run on the new head. Nothing else from the review is outstanding — the code
path had no blocking finding.

@joelmitz

Copy link
Copy Markdown
Contributor Author

Thank you — and please keep your commit on the branch. Your wording is better than mine, and
the point you added is the one I most needed someone to write down:

MAX_ARG_STRLEN has no getconf key. So the limit that actually stops you is the one the
tools will not show, and the one they will show is the one that had room to spare.

That is exactly how I got it wrong, twice, before measuring the boundary directly. Having it
in the file is worth more than the fix.

On the red shard

bats (macos-latest 1/4) failed on one test, and it does not look related to this change:

not ok 275 watch: exits when its session dies without consuming an undelivered row (#67)

tests/test_watch.bats does not reference history.sh at all (rg -l history.sh tests/test_watch*.bats returns nothing), and this PR touches only scripts/history.sh.

Run locally on Linux against this branch's head:

$ bats tests/test_watch.bats
21 tests, 0 failures   (RAW_EXIT=0)

The other three macOS shards and every Linux shard passed. So from here it reads as either a
macOS-specific timing issue in that test or ordinary flake, rather than something this branch
introduced — but I only have Linux, so I cannot confirm the macOS side myself. If it
reproduces on the re-run against your new head, I am happy to look at whatever the shard
prints; I just would not want to claim it is unrelated on stronger evidence than I have.

Scope, unchanged

Still the two history.sh sites only. The other five on the list and the pull-apply site in
the issue thread are untouched, and I am glad to extend this PR to any of them if you would
rather land them together.

@fujibee
fujibee merged commit dd61c04 into fujibee:main Aug 20, 2026
41 of 43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants