notes · · 6 min

The function that was a file

Checking a proposed edit to a large file took half a minute, and running the check on sixteen threads changed almost nothing. The cause was a single function. Splitting it with the tool itself took the check to four seconds; the work turned up six bugs in the tool.

On this page · 7 sections
  1. Sixteen threads, almost no change
  2. The log that could not be read
  3. One function, one thread
  4. Splitting it with the tool
  5. The function the tool could not touch
  6. What working through the tool found
  7. The result

Two posts back, validation moved to an engine of its own, and one number was left over: a new proposal still took about twenty-four seconds to check. A proposal here is any edit a tool wants to verify before writing it - the same check, over and over, for every refactoring in this series. Twenty-four seconds each is not a check an agent runs freely.

The first thing worth knowing was whether the size of the proposal mattered. It did not; the size of the file did. Add one function to a 50-line file and to a 3,000-line one, each time with a new name so nothing can be reused:

lang.rs     (50 lines)       0.23 s, 0.17 s
tools.rs    (3,060 lines)   24.67 s, 24.87 s
lib.rs, the gateway (3,970)  33.42 s, 33.55 s

Adding an item changes what the crate declares, and rust-analyzer then type-checks every function body in the file again before it can list the file’s errors. Name resolution for the crate is cheap - the small file shows that. Inference of the bodies is not.

Sixteen threads, almost no change

The server has 32 cores and rust-analyzer’s diagnostics pass infers one function body at a time. So the first fix wrote itself: before asking for a file’s diagnostics, infer its functions on sixteen threads, each on its own snapshot of the database, and let the diagnostics pass find them done. It compiled. The result:

lib.rs, the gateway      33.62 s, 33.41 s, 33.73 s
tools.rs                 23.57 s, 23.57 s

Nothing on the gateway, a second on tools.rs. Either the threads were not running, or they were not doing the work that mattered. The way to tell was to time the two phases separately and read the timings from the server’s log.

The log that could not be read

The timing lines never appeared. The gateway’s log went to the system journal, and the journal was busy:

systemd-journald: Suppressed 156641 messages from user@1000.service
systemd-journald: Suppressed 899479 messages from user@1000.service
systemd-journald: Suppressed 158406 messages from user@1000.service

rust-analyzer’s crates log every query they execute at info, and the gateway logged everything at info. Two validations of a large file produced about 1.2 million lines; the journal kept its burst of ten thousand and dropped the rest, and the two lines that mattered with them. The analyzer’s crates now log at warn by default (#95), and the same validation writes 26 lines. Then the timings were there:

the proposal:   primed_ms=31788  diagnostics_ms=184
the checkout:   primed_ms=732    diagnostics_ms=34

Thirty-two seconds inferring, 0.18 inferring nothing more and reporting. The threads were running. They were not helping.

One function, one thread

The reason was in the file, not in the engine. Of the gateway’s 55 functions, one was 1,533 lines long; of the MCP server’s twelve, one was 1,786:

Two columns of blocks, each block a top-level function of tools.rs in file order with its height proportional to its length. Before: twelve functions, one of them execute_tool at 1,786 lines taking most of the column, and list_tools at 703; a new proposal took 24.8 seconds. After: forty-four functions, execute_tool down to 89 lines and 32 handlers of a few dozen lines each, list_tools unchanged; a new proposal takes 3.0 seconds. One function is inferred by one thread; many small ones by many
tools.rs, one block per function. One function is inferred by one thread, however many threads there are.

execute_tool was the MCP server’s dispatcher: one match over the tool’s name with every tool’s handling written inline. Salsa can infer different functions on different threads, but one function is one query, computed by one thread from start to finish. A file that is mostly one function takes as long as that function takes, and the other fifteen threads wait.

So the fix is not in the engine. It is a refactoring - and this series is about a tool that does refactorings.

Splitting it with the tool

The rule for this repository, written down the same day (#92), is that it is worked on with the tool it builds: navigation through the analyzer, refactorings through the refactoring tools, no scripted text surgery on source files. So execute_tool was split with rust-analyzer’s own extract_function, through prod-code assist, one match arm at a time, and each extracted fun_name renamed with prod-code rename:

OK symbols 47.2s
OK safe_delete 45.3s
OK assists 42.2s
…
OK status 4.4s
OK sync 4.2s

Each line is one arm extracted and renamed, and the times are the finding restated: every step changes execute_tool, so every step makes the analyzer infer it again, and the step costs what the function costs. It falls from 47 seconds to 4 as the function shrinks from 1,786 lines to 89.

The extracted signatures came out as -> std::prelude::v1::Result<McpToolCallResult, anyhow::Error> rather than the Result already in scope - rust-analyzer spells the prelude out (#97). All 32 were fixed with one structural replacement, itself a prod-code tool, whose dry run took 0.37 s:

prod-code codemod 'std::prelude::v1::Result<$t, anyhow::Error> ==>> Result<$t>' --path crates/prod-code-mcp/src/tools.rs --apply

The function the tool could not touch

The gateway’s giant would not split. Asked what it could do with any part of it, the analyzer offered one thing:

inline_macro  [RefactorInline]  Inline macro

The whole body of the session loop sat inside tokio::select! { … }. To rust-analyzer the input of a macro call is tokens, not code, and it offers no refactoring there but inlining the macro - 1,412 lines that no refactoring could take apart, and a message, “not offered here”, that does not say why (#99).

That step was done by hand: the arm became a function, on_client_message, that returns what the loop should do next. Its 26 continues and 3 breaks all targeted the session loop - checked by walking the nesting, not assumed - so each became return Flow::Next or return Flow::Stop. The analyzer accepted the result with 0 errors, and cargo check agreed. Outside the macro, the ten LSP fast paths came out the same way as the MCP handlers.

What working through the tool found

Six bugs in one day, none of them found by the test suite, all of them while doing this work:

  • prod-code rename renamed a function to a name that already existed, leaving two definitions and reporting success (#98). A rerun of the splitting script extracted eight handlers a second time and renamed each wrapper to its handler’s name; eight duplicate functions, exit code 0. The run was thrown away and redone from a checked copy.
  • rust-analyzer panics on std::thread::scope with a move closure, and one panic failed the whole validation with a message naming neither the file nor what to do (#94). It was the first version of the parallel inference, in this very change.
  • The prelude spelling (#97), the silence inside macros (#99), the journal (#95), and a CLI that has no search by name at all, uses symbols for what the MCP server calls an outline, and prints one command’s help under another’s name (#93).

The result

a new proposal to before after the split with parallel inference too
tools.rs 24.8 s 3.95 s 3.0 s
the gateway’s lib.rs 33.5 s - 4.8 s

The split is most of it; the threads take a quarter off what is left, and they help only now that there is something to divide. The engine change that did almost nothing on its own stays, because it is the part that keeps working as the files keep changing.

The general version is older than this project: a function that is a whole file is slow to read, slow to change and, it turns out, slow to check - because every tool that understands code understands it one function at a time.

Cite this article
Citation
Alexander Panasenko (2026-10-11). The function that was a file. https://prod.codes/blog/the-function-that-was-a-file/