# How 99 Handles Request Cancellation and Process Management in Neovim

> Discover how 99 effectively manages AI query lifecycles in Neovim. Learn about request cancellation, process management via vim.system, and SIGTERM termination.

- Repository: [ThePrimeagen/99](https://github.com/theprimeagen/99)
- Tags: internals
- Published: 2026-02-19

---

**99 manages AI query lifecycles by spawning external processes through Neovim's `vim.system` API, tracking them in a centralized Request object with state machine logic, and terminating them via `SIGTERM` when users trigger cancellation.**

ThePrimeagen's `99` plugin orchestrates asynchronous AI queries by launching external binaries like `opencode`, `claude`, or `cursor-agent` as child processes. Effective **request cancellation and process management** requires coordinating three distinct layers: the Request state machine that owns the process handle, the Provider layer that spawns processes via `vim.system`, and the UI layer that visualizes active requests. This architecture ensures that terminating a request from the editor reliably kills the underlying system process and cleans up all callbacks.

## Core Architecture for Process Lifecycle Management

The plugin separates concerns across three files to maintain clean process bookkeeping and reliable cancellation semantics.

### The Request Object

Located in [`lua/99/request/init.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/request/init.lua), the `Request` class acts as the single source of truth for request state. It stores the spawned process in `self._proc` and exposes the `cancel()` method that orchestrates termination. Lines 49-66 implement the core cancellation logic, checking current state, updating to `"cancelled"`, and killing the process if it exists.

### The Provider Layer

[`lua/99/providers.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/providers.lua) contains the `BaseProvider:make_request` method (lines 71-84), which launches the external AI binary via `vim.system`. This method wires stdout and stderr callbacks, then registers the returned `vim.SystemObj` on the request object via `request:_set_process(proc)`. The provider also guards all callbacks with `request:is_cancelled()` checks to prevent processing data after cancellation.

### The UI Status Indicator

[`lua/99/ops/request_status.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/ops/request_status.lua) manages the visual spinner that appears during active requests. It uses `vim.defer_fn` to update virtual text on a timer, automatically stopping when the request state changes to `"success"`, `"failed"`, or `"cancelled"`.

## Starting a Request and Process Spawning

When a user initiates an AI query, the lifecycle begins in the Request object:

```lua
local ctx = require("99.request-context"):new()
local req = require("99.request"):new(ctx)

-- Spawns the provider process via vim.system
req:start()

```

The `req:start()` method builds the prompt and delegates to the selected provider's `make_request`. Inside [`lua/99/providers.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/providers.lua), the provider constructs the command and spawns the process:

```lua
local proc = vim.system(cmd, {
  stdout = vim.schedule_wrap(function(err, data)
    -- Callback implementation
  end),
  stderr = vim.schedule_wrap(function(err, data)
    -- Error handling
  end),
}, function(obj)
  -- Completion handler
end)

-- Store process reference on request object
request:_set_process(proc)

```

The `_set_process` method simply assigns `self._proc = proc`, creating the link necessary for later cancellation.

## Implementing Request Cancellation

### The Cancellation Flow

When a user aborts a request—via keymap or UI action—the `cancel()` method in [`lua/99/request/init.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/request/init.lua) executes a precise sequence:

1. **State validation**: Returns immediately if the request already finished (`"success"` or `"failed"`)
2. **State transition**: Sets `self.state = "cancelled"` and logs the action
3. **Process termination**: Retrieves `self._proc`, clears the reference, and sends `SIGTERM`

```lua
function Request:cancel()
  if self.state == "success" or self.state == "failed" then
    return
  end
  
  self.logger:debug("cancel")
  self.state = "cancelled"
  
  local proc = self._proc
  if proc and proc.pid then
    self._proc = nil
    
    pcall(function()
      local sigterm = (vim.uv and vim.uv.constants and vim.uv.constants.SIGTERM) or 15
      proc:kill(sigterm)
    end)
  end
end

```

The `pcall` wrapper ensures that errors during process termination—such as the PID already being invalid—do not crash the editor. The code gracefully falls back to signal `15` (standard SIGTERM) when `vim.uv.constants` is unavailable.

### Provider Callback Handling

To prevent stale data from updating buffers after cancellation, every callback in [`lua/99/providers.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/providers.lua) checks `request:is_cancelled()` before processing:

```lua
stdout = vim.schedule_wrap(function(err, data)
  if request:is_cancelled() then
    once_complete("cancelled", "")
    return
  end
  -- Process stdout data...
end),

stderr = vim.schedule_wrap(function(err, data)
  if request:is_cancelled() then
    once_complete("cancelled", "")
    return
  end
  -- Process stderr data...
end)

```

The completion handler for `vim.system` performs the same check, ensuring that `once_complete` is called exactly once with the `"cancelled"` status, which triggers the UI cleanup in `RequestStatus`.

## Process Bookkeeping and Cleanup

The plugin maintains strict ownership semantics to prevent zombie processes and memory leaks:

- **Single ownership**: Only the `Request` object holds a reference to the `vim.SystemObj` in `self._proc`
- **Null after kill**: The `cancel()` method sets `self._proc = nil` immediately before killing the process, preventing double-kill attempts
- **State machine**: Valid states (`"requesting"`, `"success"`, `"failed"`, `"cancelled"`) gate all transitions, ensuring that a completed request cannot be cancelled and a cancelled request cannot complete successfully

When the external process exits—whether naturally or via the `SIGTERM` signal—the `vim.system` completion callback updates the request state and invokes the cleanup chain, stopping the `RequestStatus` spinner and removing virtual text from the buffer.

## Practical Usage Examples

Bind cancellation to a keymap for immediate control over long-running AI queries:

```lua
local Request = require("99.request")

-- Create and start a request
local ctx = require("99.request-context"):new()
local req = Request.new(ctx)
req:start()

-- Map <leader>x to cancel the active request
vim.keymap.set("n", "<leader>x", function()
  req:cancel()
  vim.notify("AI request cancelled", vim.log.levels.INFO)
end, { desc = "Cancel current 99 request" })

```

Inspect request state programmatically to coordinate multiple operations:

```lua
print("Request state:", req.state)        -- "requesting", "cancelled", etc.
print("Process active:", req._proc ~= nil)

```

## Summary

- **99** manages external AI processes through Neovim's `vim.system` API, storing the resulting `SystemObj` in a dedicated `Request` class.
- **Cancellation** is implemented in [`lua/99/request/init.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/request/init.lua) via the `cancel()` method, which sends `SIGTERM` to the process PID and updates the request state to `"cancelled"`.
- **Defensive programming** includes `pcall` wrappers around process termination, immediate nullification of the process reference to prevent double-kills, and state machine guards that prevent transitions from terminal states.
- **Provider callbacks** in [`lua/99/providers.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/providers.lua) check `request:is_cancelled()` before processing stdout/stderr data, ensuring no buffer updates occur after user cancellation.
- **UI synchronization** is handled by `RequestStatus`, which stops its spinner automatically when the request reaches a terminal state.

## Frequently Asked Questions

### How does 99 terminate the external AI process when a user cancels a request?

When `request:cancel()` is invoked, the method checks if the request is already in a terminal state (`"success"` or `"failed"`). If active, it sets the state to `"cancelled"`, retrieves the `vim.SystemObj` from `self._proc`, and sends `SIGTERM` (signal 15) using `proc:kill()`. The kill operation is wrapped in `pcall` to prevent errors if the process already exited, and the process reference is immediately cleared to prevent duplicate termination attempts.

### What prevents 99 from processing output after a request is cancelled?

The provider layer in [`lua/99/providers.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/providers.lua) defensively checks `request:is_cancelled()` at the beginning of every stdout, stderr, and completion callback. If the request is cancelled, the callbacks invoke `once_complete("cancelled", "")` and return immediately, aborting any further data processing or buffer updates. This ensures that partial output from a terminated process never reaches the editor buffer.

### Can a completed or failed request be cancelled in 99?

No. The `cancel()` method in [`lua/99/request/init.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/request/init.lua) explicitly guards against this by returning early if `self.state` equals `"success"` or `"failed"`. This state machine pattern ensures that terminal states are immutable and prevents invalid transitions, such as attempting to kill a process that has already exited or marking a successful request as cancelled after the fact.

### How does 99 handle process cleanup if the external binary crashes or exits naturally?

When the external process exits—whether successfully, with an error, or via cancellation—the completion callback passed to `vim.system` executes. This callback checks the request state and invokes the `once_complete` function, which transitions the request to `"success"` or `"failed"` and triggers UI cleanup via `RequestStatus:stop()`. The process object is automatically managed by Neovim's `vim.system` internals, and 99 clears its internal reference (`self._proc`) during cancellation to avoid dangling pointers.