Skip to content

Commit 6b801ee

Browse files
fix(env): stop an unreadable environment setting from picking a wrong default
atoi and atol answer 0 for text they cannot read, and 0 is a real setting at three places in this project. So a typo, a trailing unit such as "30s", or a stray space silently chose a value nobody asked for, and nothing on screen said the setting had been dropped. src/mcp/index_supervisor.c CBM_INDEX_WORKER_TIMEOUT_S src/cli/hook_augment.c CBM_HOOK_DEADLINE_MS src/mcp/mcp.c CBM_INDEX_MAX_RESTARTS CBM_HOOK_DEADLINE_MS was the worst of the three. atoi answered 0, 0 is below HA_DEADLINE_MIN_MS, and the clamp then handed back 50 ms -- the SHORTEST deadline the setting allows, for a setting whose only purpose is to give the hook more room. The comment above that function records a hunt for hook runs that never finished, 0 of 24 real sessions, which is the exact symptom a silently-shortened deadline produces. CBM_INDEX_MAX_RESTARTS lost twice. A typo kept the default of 100, and CBM_INDEX_MAX_RESTARTS=0 -- which reads as "do not restart" to anybody who sets it -- also kept 100. The setting did the opposite of the request. CBM_INDEX_WORKER_TIMEOUT_S fell through to the 15-minute default, so a test set to give up after 30 seconds hung for 15 minutes with nothing to explain why. The fix adds one helper rather than three copies of the same check: bool cbm_env_long(const char *name, long *out); It answers true only when the variable is set, is not empty, and reads cleanly from its first character to its last. It holds no policy -- no minimum, no maximum, no default -- because the three sites disagree on all three, and a helper that guessed would be wrong at two of them. The shape is the one src/main.c:1104 already uses: an end pointer, errno, and a check that nothing was left over. It also refuses a leading blank, which strtol would otherwise step over, so " 5" is a slip rather than the number 5. Each site keeps its own rule: worker timeout an unreadable value keeps the 15-minute default AND logs the value it dropped restart cap 0 now means no restarts; an unreadable value keeps 100 AND logs the value it dropped hook deadline an unreadable value now yields HA_DEADLINE_DEFAULT_MS, not the floor. This one stays silent on purpose: the file includes no log header and writes no stderr, because its output is hook protocol. The restart-cap parse was lifted out of a very large function into a named index_restart_cap(), so it can be read and reached on its own. Four tests come with the change. The two that pin the user-visible behaviour were seen failing before the fix: FAIL tests/test_cli.c:402: ms == 50, expected HOOK_DEADLINE_DEFAULT == 2000 (with "unreadable value \"abc\" gave 50 ms" printed above it) FAIL tests/test_cli.c:434: cbm_index_restart_cap_for_testing() == 100, expected 0 == 0 After the fix, TEST_SUITES="platform cli mcp" reports 518 passed, 2 failed. The full suite reports 7635 passed, 2 failed. Both failures are in tests/test_cli.c (lines 1826 and 6802), print "error: one or more agent cleanup operations failed", and reproduce on a clean tree without this change -- they depend on the coding agents installed on the machine. make -f Makefile.cbm lint-ci passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Joshua Richter <jrichter5781@gmail.com>
1 parent c38aa35 commit 6b801ee

9 files changed

Lines changed: 263 additions & 16 deletions

File tree

src/cli/cli.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,13 @@ char *cbm_hook_augment_lifecycle_json_for(const char *input, const char *forced_
525525
/* Thin daemon frontend support: preserve the hook's bounded stdin read and
526526
* hard fail-open deadline without constructing a local MCP/store instance. */
527527
void cbm_hook_augment_arm_deadline(void);
528+
529+
/* The in-process deadline in milliseconds, as CBM_HOOK_DEADLINE_MS resolves it.
530+
* Exposed so a test can check what an unreadable value falls back to. POSIX
531+
* only: the Windows path arms a fixed timer and reads no environment value. */
532+
#ifndef _WIN32
533+
int cbm_hook_augment_deadline_ms_for_testing(void);
534+
#endif
528535
char *cbm_hook_augment_read_stdin(void);
529536
/* Pure no-op gate for the hook-client fast path (see hook_augment.c). */
530537
bool cbm_hook_augment_input_is_noop_bash(const char *input);

src/cli/hook_augment.c

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
#include "foundation/compat_fs.h"
2121
#include "foundation/constants.h"
2222
#include "foundation/mem.h"
23+
#include "foundation/platform.h"
2324
#include "mcp/mcp.h"
2425
#include "pipeline/pipeline.h"
2526
#include "yyjson/yyjson.h"
@@ -70,18 +71,21 @@
7071
* hook "timeout" remains the outer backstop (and alone governs Windows,
7172
* where this whole in-process deadline block is compiled out). */
7273
static int ha_deadline_ms(void) {
73-
const char *env = getenv("CBM_HOOK_DEADLINE_MS");
74-
if (!env || !env[0]) {
74+
/* A value this reader cannot read gets the DEFAULT, never the floor. atoi
75+
* used to answer 0 for a typo, 0 is below the minimum, and the clamp then
76+
* handed back the shortest deadline the setting allows — the opposite of
77+
* what somebody raising CBM_HOOK_DEADLINE_MS is asking for. */
78+
long v = 0;
79+
if (!cbm_env_long("CBM_HOOK_DEADLINE_MS", &v)) {
7580
return HA_DEADLINE_DEFAULT_MS;
7681
}
77-
int v = atoi(env);
7882
if (v < HA_DEADLINE_MIN_MS) {
7983
return HA_DEADLINE_MIN_MS;
8084
}
8185
if (v > HA_DEADLINE_MAX_MS) {
8286
return HA_DEADLINE_MAX_MS;
8387
}
84-
return v;
88+
return (int)v;
8589
}
8690

8791
static int g_ha_crumb_fd = -1;
@@ -123,6 +127,10 @@ static void ha_open_crumb_log(int deadline_ms) {
123127
g_ha_crumb_len = (n > 0 && n < (int)sizeof(g_ha_crumb_msg)) ? (size_t)n : 0;
124128
}
125129

130+
int cbm_hook_augment_deadline_ms_for_testing(void) {
131+
return ha_deadline_ms();
132+
}
133+
126134
void cbm_hook_augment_arm_deadline(void) {
127135
int ms = ha_deadline_ms();
128136
ha_open_crumb_log(ms);

src/foundation/platform.c

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
#include "foundation/compat.h"
99
#include "foundation/constants.h"
1010
#include "foundation/platform_internal.h"
11+
#include <ctype.h>
12+
#include <errno.h>
1113
#include <fcntl.h>
1214
#include <stdint.h>
1315
#include <stdio.h>
@@ -435,6 +437,33 @@ const char *cbm_safe_getenv(const char *name, char *buf, size_t buf_sz, const ch
435437
return NULL;
436438
}
437439

440+
/* See platform.h. The shape here is the one src/main.c:1104 already uses for
441+
* --port=: an end pointer says where the read stopped, errno catches a number
442+
* too large, and *end == '\0' catches anything left over. */
443+
bool cbm_env_long(const char *name, long *out) {
444+
if (!out) {
445+
return false;
446+
}
447+
char raw[CBM_SZ_64] = {0};
448+
if (!cbm_safe_getenv(name, raw, sizeof(raw), NULL) || !raw[0]) {
449+
return false;
450+
}
451+
/* strtol skips leading blanks of its own accord, so " 5" would read as 5.
452+
* A blank in front of a setting is a slip, not a number, so refuse it here
453+
* rather than let strtol quietly step over it. */
454+
if (isspace((unsigned char)raw[0])) {
455+
return false;
456+
}
457+
char *end = NULL;
458+
errno = 0;
459+
long value = strtol(raw, &end, CBM_DECIMAL_BASE);
460+
if (errno != 0 || !end || end == raw || *end != '\0') {
461+
return false;
462+
}
463+
*out = value;
464+
return true;
465+
}
466+
438467
/* ── Home directory (cross-platform) ───────────────────── */
439468

440469
const char *cbm_get_home_dir(void) {

src/foundation/platform.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,18 @@ int cbm_default_worker_count(bool initial);
121121
* Returns NULL when the variable is unset and fallback is NULL. */
122122
const char *cbm_safe_getenv(const char *name, char *buf, size_t buf_sz, const char *fallback);
123123

124+
/* Read an environment variable as a whole number.
125+
*
126+
* Answers true only when the variable is set, is not empty, and reads cleanly
127+
* from its first character to its last. Anything else — a typo, a trailing
128+
* unit such as "30s", a leading or trailing space, or a number too large for a
129+
* long — answers false and leaves *out untouched, so the caller picks its own
130+
* fallback and can say that it did.
131+
*
132+
* This exists because atoi and atol answer 0 for text they cannot read, and 0
133+
* is a real setting at every call site in this project. */
134+
bool cbm_env_long(const char *name, long *out);
135+
124136
/* ── Home directory ─────────────────────────────────────────────── */
125137

126138
/* Cross-platform home directory: tries HOME first, then USERPROFILE (Windows).

src/mcp/index_supervisor.c

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include "foundation/profile.h" /* cbm_profile_active (keep worker log under CBM_PROFILE) */
1313
#include "ui/http_server.h" /* cbm_http_server_resolve_binary_path */
1414

15+
#include <limits.h>
1516
#include <stdio.h>
1617
#include <stdint.h>
1718
#include <stdlib.h>
@@ -322,14 +323,23 @@ static bool supervisor_disable_requested(void) {
322323
* CBM_INDEX_WORKER_TIMEOUT_S override (seconds → ms) tightens it for tests. */
323324
static int worker_quiet_timeout_ms(void) {
324325
enum { DEFAULT_QUIET_TIMEOUT_MS = 900000 }; /* 15 min with no progress */
326+
enum { MS_PER_SECOND = 1000 };
325327
char timeout_seconds[CBM_SZ_32] = {0};
328+
long s = 0;
329+
/* The upper test only stops the seconds-to-ms multiply from overflowing an
330+
* int. It sets no policy: a longer timeout than the default is still fine. */
331+
if (cbm_env_long("CBM_INDEX_WORKER_TIMEOUT_S", &s) && s > 0 && s <= INT_MAX / MS_PER_SECOND) {
332+
return (int)(s * MS_PER_SECOND);
333+
}
334+
/* atol used to answer 0 for a value it could not read, and 0 fell straight
335+
* through to the 15-minute default with nothing on screen. A test set to
336+
* give up after 30 seconds then hung for 15 minutes and nobody could see
337+
* why. An unreadable value now says so before it is dropped. */
326338
if (cbm_safe_getenv("CBM_INDEX_WORKER_TIMEOUT_S", timeout_seconds, sizeof(timeout_seconds),
327339
NULL) &&
328340
timeout_seconds[0]) {
329-
long s = atol(timeout_seconds);
330-
if (s > 0) {
331-
return (int)(s * 1000);
332-
}
341+
cbm_log_warn("index.supervisor.worker_timeout_ignored", "value", timeout_seconds, "action",
342+
"using_default");
333343
}
334344
return DEFAULT_QUIET_TIMEOUT_MS;
335345
}

src/mcp/mcp.c

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8374,6 +8374,34 @@ cbm_mcp_supervised_result_disposition_t cbm_mcp_supervised_result_disposition(
83748374
* - a contained-failure response only if even that cannot produce a clean run.
83758375
* A physical CBM host never falls back to its in-process pipeline: an initial
83768376
* start/protocol failure is returned as an explicit error response. */
8377+
/* How many times a failed index worker may be re-run before the server gives
8378+
* up, as CBM_INDEX_MAX_RESTARTS sets it. Default 100. */
8379+
static int index_restart_cap(void) {
8380+
enum { INDEX_RESTART_CAP_DEFAULT = 100 };
8381+
long v = 0;
8382+
if (!cbm_env_long("CBM_INDEX_MAX_RESTARTS", &v)) {
8383+
/* Unset is the ordinary case and says nothing. A value that is set but
8384+
* unreadable is a person's intent being dropped, so name it. */
8385+
char raw[CBM_SZ_64] = {0};
8386+
if (cbm_safe_getenv("CBM_INDEX_MAX_RESTARTS", raw, sizeof(raw), NULL) && raw[0]) {
8387+
cbm_log_warn("index.restart_cap.ignored", "value", raw, "action", "using_default");
8388+
}
8389+
return INDEX_RESTART_CAP_DEFAULT;
8390+
}
8391+
/* Zero is a real answer meaning no restarts. The old reader kept the
8392+
* default unless the number was above zero, so the one value somebody sets
8393+
* to leave the worker alone did the opposite. */
8394+
if (v < 0 || v > INT_MAX) {
8395+
cbm_log_warn("index.restart_cap.out_of_range", "action", "using_default");
8396+
return INDEX_RESTART_CAP_DEFAULT;
8397+
}
8398+
return (int)v;
8399+
}
8400+
8401+
int cbm_index_restart_cap_for_testing(void) {
8402+
return index_restart_cap();
8403+
}
8404+
83778405
static char *index_run_supervised(cbm_mcp_server_t *srv, const char *args) {
83788406
invalidate_cached_store(srv);
83798407

@@ -8437,14 +8465,7 @@ static char *index_run_supervised(cbm_mcp_server_t *srv, const char *args) {
84378465
(void)fclose(qinit);
84388466
}
84398467

8440-
int cap = 100;
8441-
const char *cap_env = getenv("CBM_INDEX_MAX_RESTARTS");
8442-
if (cap_env && cap_env[0]) {
8443-
int v = atoi(cap_env);
8444-
if (v > 0) {
8445-
cap = v;
8446-
}
8447-
}
8468+
int cap = index_restart_cap();
84488469

84498470
char *resp = NULL;
84508471
int quarantined = 0; /* files pinned + added to the quarantine list so far */

src/mcp/mcp.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,4 +290,8 @@ void cbm_mcp_server_request_scope_end(cbm_mcp_server_t *srv);
290290
* On Windows, strips leading / from /C:/path. */
291291
bool cbm_parse_file_uri(const char *uri, char *out_path, int out_size);
292292

293+
/* How many restarts a failed index worker gets, as CBM_INDEX_MAX_RESTARTS sets
294+
* it. Exposed so a test can check what the setting resolves to. */
295+
int cbm_index_restart_cap_for_testing(void);
296+
293297
#endif /* CBM_MCP_H */

tests/test_cli.c

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,83 @@ static void restore_test_env(const char *name, char *saved) {
367367
}
368368
}
369369

370+
/* An unreadable CBM_HOOK_DEADLINE_MS must fall back to the DEFAULT budget, not
371+
* to the shortest one the setting allows.
372+
*
373+
* atoi answers 0 for text it cannot read, and 0 is below HA_DEADLINE_MIN_MS, so
374+
* the clamp used to hand back 50 ms -- the worst possible answer for a setting
375+
* whose whole purpose is to give the hook more room. The comment above
376+
* ha_deadline_ms records a hunt for hook runs that never finished (0 of 24 real
377+
* sessions), which is exactly the symptom a silently-shortened deadline makes.
378+
*
379+
* POSIX only: the Windows path arms a fixed timer and reads no environment. */
380+
#ifndef _WIN32
381+
TEST(cli_hook_deadline_ignores_an_unreadable_value) {
382+
enum { HOOK_DEADLINE_DEFAULT = 2000, HOOK_DEADLINE_MIN = 50, HOOK_DEADLINE_MAX = 10000 };
383+
char *saved = save_test_env("CBM_HOOK_DEADLINE_MS");
384+
385+
/* Positive control: a good value is still used, so a failure below is about
386+
* the unreadable case and not about the reader being broken outright. */
387+
cbm_setenv("CBM_HOOK_DEADLINE_MS", "1234", 1);
388+
ASSERT_EQ(cbm_hook_augment_deadline_ms_for_testing(), 1234);
389+
390+
/* Unset falls back to the default. */
391+
cbm_unsetenv("CBM_HOOK_DEADLINE_MS");
392+
ASSERT_EQ(cbm_hook_augment_deadline_ms_for_testing(), HOOK_DEADLINE_DEFAULT);
393+
394+
/* The claim: text the reader cannot read gets the default, never the floor. */
395+
const char *unreadable[] = {"abc", "2000ms", " 2000", "2000 ", "", "1e3"};
396+
for (size_t i = 0; i < sizeof(unreadable) / sizeof(unreadable[0]); i++) {
397+
cbm_setenv("CBM_HOOK_DEADLINE_MS", unreadable[i], 1);
398+
int ms = cbm_hook_augment_deadline_ms_for_testing();
399+
if (ms != HOOK_DEADLINE_DEFAULT) {
400+
printf(" unreadable value \"%s\" gave %d ms\n", unreadable[i], ms);
401+
}
402+
ASSERT_EQ(ms, HOOK_DEADLINE_DEFAULT);
403+
}
404+
405+
/* Both clamps still hold for values that DO read. */
406+
cbm_setenv("CBM_HOOK_DEADLINE_MS", "1", 1);
407+
ASSERT_EQ(cbm_hook_augment_deadline_ms_for_testing(), HOOK_DEADLINE_MIN);
408+
cbm_setenv("CBM_HOOK_DEADLINE_MS", "999999", 1);
409+
ASSERT_EQ(cbm_hook_augment_deadline_ms_for_testing(), HOOK_DEADLINE_MAX);
410+
411+
restore_test_env("CBM_HOOK_DEADLINE_MS", saved);
412+
PASS();
413+
}
414+
#endif
415+
416+
/* CBM_INDEX_MAX_RESTARTS=0 means no restarts. It used to mean 100 of them.
417+
*
418+
* The old reader kept the default unless atoi answered greater than zero, so
419+
* the one value a person sets when they want the worker left alone did the
420+
* opposite. A typo did the same thing, with nothing on screen either way. */
421+
TEST(cli_index_restart_cap_honours_zero_and_refuses_junk) {
422+
enum { INDEX_RESTART_CAP_DEFAULT = 100 };
423+
char *saved = save_test_env("CBM_INDEX_MAX_RESTARTS");
424+
425+
/* Positive control: a good value is still used. */
426+
cbm_setenv("CBM_INDEX_MAX_RESTARTS", "7", 1);
427+
ASSERT_EQ(cbm_index_restart_cap_for_testing(), 7);
428+
429+
cbm_unsetenv("CBM_INDEX_MAX_RESTARTS");
430+
ASSERT_EQ(cbm_index_restart_cap_for_testing(), INDEX_RESTART_CAP_DEFAULT);
431+
432+
/* The claim: zero is a real answer meaning no restarts. */
433+
cbm_setenv("CBM_INDEX_MAX_RESTARTS", "0", 1);
434+
ASSERT_EQ(cbm_index_restart_cap_for_testing(), 0);
435+
436+
/* Text the reader cannot read keeps the default. */
437+
const char *unreadable[] = {"abc", "5x", " 5", "5 ", ""};
438+
for (size_t i = 0; i < sizeof(unreadable) / sizeof(unreadable[0]); i++) {
439+
cbm_setenv("CBM_INDEX_MAX_RESTARTS", unreadable[i], 1);
440+
ASSERT_EQ(cbm_index_restart_cap_for_testing(), INDEX_RESTART_CAP_DEFAULT);
441+
}
442+
443+
restore_test_env("CBM_INDEX_MAX_RESTARTS", saved);
444+
PASS();
445+
}
446+
370447
/* Helper: mkdirp */
371448
static int test_mkdirp(const char *path) {
372449
char tmp[1024];
@@ -13686,6 +13763,10 @@ TEST(cli_update_only_names_an_installer_that_exists_issue1632) {
1368613763

1368713764
SUITE(cli) {
1368813765
RUN_TEST(cli_update_only_names_an_installer_that_exists_issue1632);
13766+
#ifndef _WIN32
13767+
RUN_TEST(cli_hook_deadline_ignores_an_unreadable_value);
13768+
#endif
13769+
RUN_TEST(cli_index_restart_cap_honours_zero_and_refuses_junk);
1368913770
RUN_TEST(cli_progress_visibility_policy);
1369013771
RUN_TEST(cli_raw_mcp_result_preserves_tool_error_status);
1369113772
RUN_TEST(cli_maintenance_cancellation_forces_failure_status);

0 commit comments

Comments
 (0)