Skip to content

Idle language servers stay running while their project root is in use: idle reap is per root, not per server (31.5 GiB of tsserver in swap) #1170

Closed

Description

@iceteaSA

Problem

AFT stops language servers only when a whole project root is idle. A root that has continuous use keeps every server that it started, also servers for package directories that nobody has used for hours. TypeScript servers use approximately 1 GiB each, so the memory increases with each new package directory, and the operating system moves them to swap.

What I measured (aft 0.62.0, one module process, Linux)

  • 34 typescript-language-server processes (68 tsserver.js processes), all children of the aft process. Together they used 31.5 GiB of swap and 7 GiB of resident memory. The aft process itself used 0.9 GiB resident and 2.5 GiB swap.
  • Each server has a different package directory (the nearest tsconfig.json). Most of the directories are git worktrees in a gitignored directory below the project root. Agents in long sessions in that root create a new worktree for each task.
  • Server ages were 0.2 h to 10.7 h. For one server, the last request that AFT sent to it was 8 hours before the measurement.
  • In 12.5 h, the module log has one idle lsp reap line (a different root). lsp.idle_minutes is not set, so the default (60) applies.
  • I stopped one server with SIGTERM. AFT logged exited TypeScript … (eof) and started a new server on the next request. The diagnostics were correct. Thus a stopped server has no bad effect, but AFT does not stop idle servers itself.

Cause (v0.62.0)

reap_idle_lsp_servers (crates/aft/src/subc/mod.rs:2607) calls shutdown_idle_lsp_at with the root's last_touched. shutdown_idle_lsp_at (crates/aft/src/runtime_drain.rs:4051) compares the idle time of the root with the TTL. Then it stops all clients with take_all_clients(), or it stops none. last_touched changes on each tool call for the root. So while one session in the root makes calls, no server of that root stops. This is also true for a server that got no request for hours. There is no limit on the number of servers for each root.

The standalone path (shutdown_idle_lsp, which uses last_request_at()) has the same all-or-nothing behavior.

Suggested change

  • Record the last use for each server (a request sent to it, or a document open, change or save for a file that it serves).
  • In the sweep, stop each server whose last use is older than lsp.idle_minutes, also when the root is in use. Use the current background stop (spawn_idle_lsp_reap).
  • Possibly: a limit on the number of servers for each root, which stops the server with the oldest last use when a new server starts.

Other observation

A memory check that reads only the aft process (resident memory and swap) does not include the language server children. So that check does not see this increase.

Activity

  1. iceteaSA commented on Oct 30, 2026

    @iceteaSA
    ContributorAuthor

    Thanks, I see 7f9d0c0 on main (per-client idle reap). I corrected the line references in the description: they now point to v0.62.0, where I measured the problem. Before, they pointed to an old commit.

  2. aft-alfonso commented on Oct 30, 2026

    @aft-alfonso

    Thanks for the detailed measurements and source investigation. Your diagnosis is correct: the idle sweep used the project's activity timestamp and kept all its language servers alive while any tool call kept the project busy. The standalone sweep had the same all-or-nothing behavior.

    The fix tracks each server's outgoing requests and document open/change/save notifications, and stops idle servers in the background after lsp.idle_minutes even when another package in the project is busy. In-flight requests are protected, and the next request to a reaped package starts a new server. This is fixed in 0.63.4, released today.

  3. aft-alfonso commented on Oct 30, 2026

    @aft-alfonso

    Released in v0.63.4.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions