From f3bf65d72a9c8e02c33cd143b5393b248531ac5b Mon Sep 17 00:00:00 2001 From: joelmitz Date: Wed, 19 Aug 2026 23:06:42 +0900 Subject: [PATCH 1/2] fix(history): pass the history and unread-marker SQL on stdin (#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('') :64 the recipient's unread backlog, via json_each('') 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 "" 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. --- scripts/history.sh | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/scripts/history.sh b/scripts/history.sh index 7d4040e3c..2bc6bfff2 100755 --- a/scripts/history.sh +++ b/scripts/history.sh @@ -38,14 +38,23 @@ fi # Parse to "from \x1f to \x1f body \x1f at \x1f id" rows (no jq; cf. lib/hooks-json.sh). _arr="[$(printf '%s' "$HIST_JSONL" | paste -sd, -)]" -ROWS=$(agmsg_sqlite ':memory:' " - SELECT json_extract(value,'\$.from') || char(31) || - json_extract(value,'\$.to') || char(31) || - replace(replace(json_extract(value,'\$.body'), char(10), '\n'), char(9), '\t') || char(31) || - json_extract(value,'\$.at') || char(31) || - json_extract(value,'\$.id') - FROM json_each('$(printf '%s' "$_arr" | sed "s/'/''/g")'); -") +# #777: same argv-length exposure on the display path. Capping --limit does not bound this +# one either, because a single long body can carry it past the ceiling on its own. +_agmsg_rows_sql=$(mktemp "${TMPDIR:-/tmp}/agmsg-history-rows.XXXXXX") || exit 13 +trap 'rm -f "$_agmsg_rows_sql"' EXIT HUP INT TERM +{ + printf "%s\n" "SELECT json_extract(value,'\$.from') || char(31) ||" + printf "%s\n" " json_extract(value,'\$.to') || char(31) ||" + printf "%s\n" " replace(replace(json_extract(value,'\$.body'), char(10), '\n'), char(9), '\t') || char(31) ||" + printf "%s\n" " json_extract(value,'\$.at') || char(31) ||" + printf "%s\n" " json_extract(value,'\$.id')" + printf "FROM json_each('" + printf '%s' "$_arr" | sed "s/'/''/g" + printf "');\n" +} > "$_agmsg_rows_sql" +ROWS=$(agmsg_sqlite ':memory:' < "$_agmsg_rows_sql") +rm -f "$_agmsg_rows_sql" +trap - EXIT HUP INT TERM # Read-state for the ●(unread)/○(read) marker (G2(c)): read-state is # recipient-scoped and not carried on a history record, so derive it by unioning @@ -61,9 +70,21 @@ while IFS= read -r r; do u=$(storage_list_unread "$TEAM" "$r") || continue [ -n "$u" ] || continue uarr="[$(printf '%s' "$u" | paste -sd, -)]" - ids=$(agmsg_sqlite ':memory:' " - SELECT json_extract(value,'\$.id') FROM json_each('$(printf '%s' "$uarr" | sed "s/'/''/g")'); - ") + # #777: a recipient's unread backlog grows independently of the display limit, so + # interpolating it into one argv element eventually exceeds ARG_MAX (measured on Linux: + # 2,097,152 was not enough for a 2,079-message team). Pass the statement on stdin + # instead, mirroring drivers/storage/sqlite-sync.sh:1082. printf is a bash builtin, so + # feeding it a large value does not exec and cannot hit ARG_MAX. + _agmsg_unread_sql=$(mktemp "${TMPDIR:-/tmp}/agmsg-history-unread.XXXXXX") || continue + trap 'rm -f "$_agmsg_unread_sql"' EXIT HUP INT TERM + { + printf "SELECT json_extract(value,'\$.id') FROM json_each('" + printf '%s' "$uarr" | sed "s/'/''/g" + printf "');\n" + } > "$_agmsg_unread_sql" + ids=$(agmsg_sqlite ':memory:' < "$_agmsg_unread_sql") + rm -f "$_agmsg_unread_sql" + trap - EXIT HUP INT TERM UNREAD_IDS+="$ids"$'\n' done <<< "$RECIPIENTS" From ac4bc91a40d353021792f7deaf9d541f15ab0eef Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 19 Aug 2026 16:44:50 -0700 Subject: [PATCH 2/2] docs(history): name the limit that was actually hit 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. --- scripts/history.sh | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/history.sh b/scripts/history.sh index 2bc6bfff2..9ba4d34b1 100755 --- a/scripts/history.sh +++ b/scripts/history.sh @@ -71,10 +71,19 @@ while IFS= read -r r; do [ -n "$u" ] || continue uarr="[$(printf '%s' "$u" | paste -sd, -)]" # #777: a recipient's unread backlog grows independently of the display limit, so - # interpolating it into one argv element eventually exceeds ARG_MAX (measured on Linux: - # 2,097,152 was not enough for a 2,079-message team). Pass the statement on stdin - # instead, mirroring drivers/storage/sqlite-sync.sh:1082. printf is a bash builtin, so - # feeding it a large value does not exec and cannot hit ARG_MAX. + # interpolating it into one argv element eventually exceeds the ceiling on a SINGLE + # argument -- on Linux `MAX_ARG_STRLEN`, 131,072 bytes. Measured: the failing + # statement for a 2,079-message team was 125,945 bytes, which is nowhere near + # `ARG_MAX` (2,097,152 here) because ARG_MAX bounds argv plus environment in total, + # not any one element of it. The distinction decides the repair: splitting one long + # statement into several shorter arguments satisfies MAX_ARG_STRLEN and leaves + # ARG_MAX untouched, and a reader who has the wrong limit in mind reaches for the + # wrong fix. Note also that MAX_ARG_STRLEN is a kernel constant with no getconf key, + # so the limit that bites is the one the tools cannot show you. + # + # Pass the statement on stdin instead, mirroring drivers/storage/sqlite-sync.sh:1082. + # printf is a bash builtin, so feeding it a large value does not exec at all and can + # hit neither ceiling. _agmsg_unread_sql=$(mktemp "${TMPDIR:-/tmp}/agmsg-history-unread.XXXXXX") || continue trap 'rm -f "$_agmsg_unread_sql"' EXIT HUP INT TERM {