Skip to content

fix(runtime): give the agent turn path a 16 MiB stack - #985

Open
raskevichai wants to merge 1 commit into
nullclaw:mainfrom
raskevichai:fix/session-turn-stack-size
Open

fix(runtime): give the agent turn path a 16 MiB stack#985
raskevichai wants to merge 1 commit into
nullclaw:mainfrom
raskevichai:fix/session-turn-stack-size

Conversation

@raskevichai

Copy link
Copy Markdown
Contributor

Closes #976.

Root cause

SESSION_TURN_STACK_SIZE was aliased to HEAVY_RUNTIME_STACK_SIZE:

pub const SESSION_TURN_STACK_SIZE: usize = HEAVY_RUNTIME_STACK_SIZE;  // 2 MiB

That constant sizes every thread which runs SessionManager.processMessage*() / Agent.turn(). The turn path is the deepest stack in the runtime (channel decode -> session -> agent loop -> provider -> tool dispatch), and aarch64 frames are larger than x86_64 ones, so 2 MiB was not enough there: the stack pointer crossed into the PROT_NONE guard page below the thread stack and the process died on every inbound message.

nullclaw agent -m "hi" was never affected because it runs on the 8 MiB main-thread stack, which is why the config, provider and LLM paths all looked healthy.

Why 16 MiB

The reporter established the mechanism from both directions:

  • At the crash $sp was inside the guard mapping immediately below the worker stack, and the return address appeared zero times on the stack - so a stack overflow, not runaway recursion.
  • An LD_PRELOAD shim clamping every pthread_attr_setstacksize call to 16 MiB made the bot reply normally with NRestarts staying at 0.

Their LD_PRELOAD trace observed 512 KiB and 2 MiB stacks, which line up exactly with COORDINATION_STACK_SIZE / AUXILIARY_LOOP_STACK_SIZE (512 KiB) and HEAVY_RUNTIME_STACK_SIZE (2 MiB) in thread_stacks.zig.

16 MiB is the value verified on the affected hardware. 8 MiB is only known to be sufficient on x86_64 (that is the main-thread size), so it is not a safe target for the platform that actually fails.

This is address space, not resident memory. Linux commits stack pages on first touch, so a thread that stays shallow still costs a few pages of RSS. The ~1 MB peak RSS constraint in CLAUDE.md is unaffected; virtual size grows, RSS does not.

Radius

One constant, but it covers every thread that executes a turn:

  • the five channel polling loops (spawnTelegramPolling and siblings)
  • the parallel per-session message workers (messageTaskWorker)
  • the gateway turn thread
  • inboundDispatcherThread in daemon.zig - which is literally the "inbound worker" named in the issue

The alias to HEAVY_RUNTIME_STACK_SIZE is also dropped: websocket gateway loops and subagents have genuinely different depth requirements from the turn path, and tying them together is what let this regress silently.

Tests

  • session turn stack size can spawn a thread - matches the existing pattern for the other budgets, and guards against a size the pthread backend would reject.
  • session turn stack clears the main-thread budget - asserts the budget exceeds both the 8 MiB main-thread reference and HEAVY_RUNTIME_STACK_SIZE, so re-aliasing the constant fails the build.

Sanity check: restoring = HEAVY_RUNTIME_STACK_SIZE makes the second test fail; restoring the fix makes the suite green again.

Verification

zig build test --summary all   # 7275 pass, 14 skip, 0 fail

I do not have aarch64 hardware, so I could not reproduce the segfault or measure the true peak stack usage of the turn path. This change follows the reporter's diagnosis and their verified 16 MiB workaround. Confirmation from anyone running a Raspberry Pi or other aarch64 host would be worth having before release.

Related

Two commenters on the issue attributed this to the default Zig stack size (~512 KiB) in channel_loop.zig / telegram.zig. That is not what the code does - the size is set explicitly at every spawn site through thread_stacks.zig, and the threads on the turn path were getting 2 MiB.

On aarch64 Linux every inbound Telegram message segfaulted. Under
systemd with Restart=always the process crash-looped, one restart per
message, and the message was dropped without a reply.

SESSION_TURN_STACK_SIZE was aliased to HEAVY_RUNTIME_STACK_SIZE, so the
threads that run SessionManager.processMessage*() / Agent.turn() got
2 MiB. The turn path is the deepest stack in the runtime -- channel
decode, session, agent loop, provider call, tool dispatch -- and aarch64
frames are larger than x86_64 ones, so the stack pointer crossed into
the PROT_NONE guard page below the thread stack.

The reporter confirmed the mechanism from both sides: at the crash $sp
sat inside the guard mapping, the return address appeared zero times on
the stack (so not runaway recursion), and an LD_PRELOAD shim clamping
every pthread stack to 16 MiB made the bot answer normally with zero
restarts. `nullclaw agent` was never affected because it runs on the
8 MiB main-thread stack.

Set the budget to 16 MiB and stop aliasing the heavy-runtime constant,
since the two roles have genuinely different depth requirements. This
covers every thread that executes a turn: the five channel polling
loops, the parallel per-session message workers, the gateway turn
thread, and the inbound dispatcher.

Stack size is address space, not resident memory -- Linux commits stack
pages on first touch, so threads that stay shallow still cost a few
pages of RSS.

Closes nullclaw#976.
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.

SIGSEGV on every inbound Telegram message — inbound worker thread spawned with a ~512 KB stack overflows

1 participant