How 99 Handles Request Cancellation and Process Management in Neovim
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, 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 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 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:
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, the provider constructs the command and spawns the process:
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 executes a precise sequence:
- State validation: Returns immediately if the request already finished (
"success"or"failed") - State transition: Sets
self.state = "cancelled"and logs the action - Process termination: Retrieves
self._proc, clears the reference, and sendsSIGTERM
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 checks request:is_cancelled() before processing:
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
Requestobject holds a reference to thevim.SystemObjinself._proc - Null after kill: The
cancel()method setsself._proc = nilimmediately 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:
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:
print("Request state:", req.state) -- "requesting", "cancelled", etc.
print("Process active:", req._proc ~= nil)
Summary
- 99 manages external AI processes through Neovim's
vim.systemAPI, storing the resultingSystemObjin a dedicatedRequestclass. - Cancellation is implemented in
lua/99/request/init.luavia thecancel()method, which sendsSIGTERMto the process PID and updates the request state to"cancelled". - Defensive programming includes
pcallwrappers 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.luacheckrequest: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 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 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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →