Metadata-Version: 2.4
Name: csharp-ls-proxy
Version: 0.7.0
Summary: Stdio LSP proxy in front of csharp-ls that works around a Claude Code LSP client bug.
Author-email: Gabriel Farfan <gabriel@midireccion.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/GabrielFarfan/csharp-ls-proxy
Project-URL: Repository, https://github.com/GabrielFarfan/csharp-ls-proxy
Project-URL: Issues, https://github.com/GabrielFarfan/csharp-ls-proxy/issues
Keywords: lsp,csharp,csharp-ls,claude-code,proxy,language-server
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# csharp-ls-proxy

A small stdio proxy that sits between **Claude Code** and **[csharp-ls](https://github.com/razzmatazz/csharp-language-server)** to work around a Claude Code LSP client bug. Single Python script, zero runtime dependencies. Runs on Linux (Debian-tested) and macOS — install and behavior are identical, except one Linux-only orphan protection (see the [macOS note](#macos-hard-kill-gap)).

## The problem

Claude Code's LSP client does not register handlers for three server→client LSP
requests that `csharp-ls` sends during initialization:

- `client/registerCapability`
- `workspace/configuration`
- `window/workDoneProgress/create`

Claude Code answers all three with JSON-RPC `-32601 "Unhandled method"`. The last
one causes `csharp-ls` to abort solution loading entirely, so no C# code
intelligence ever becomes available.

See [`csharp-lsp-bug-report.md`](csharp-lsp-bug-report.md) for the full trace and
root-cause analysis.

## The fix

`csharp_ls_proxy.py` forwards every LSP message unchanged in both directions,
**except** that it answers the known-problematic server requests locally with
spec-valid default responses and never forwards them to Claude Code. `csharp-ls`
then sees a compliant client and loads the solution normally.

The proxy also:

- rewrites `textDocument/documentSymbol` responses by default so symbol positions point at the identifier line rather than leading doc-comment/attribute trivia — see [documentSymbol range fix](#documentsymbol-range-fix);
- fails loudly (logged `FrameError`) on malformed LSP frames; caps header (64 KiB) and body (512 MiB) sizes so a peer can't force unbounded memory growth;
- emits a stderr warning when it declines an action-bearing server request (`workspace/applyEdit`, `window/showMessageRequest`, `window/showDocument`), so a dropped edit/dialog is diagnosable instead of silent;
- drains a final buffered server message on shutdown and tears down cleanly (no SIGPIPE death, no hung child; teardown escalates stdin-EOF → SIGTERM → SIGKILL);
- binds the real server's life to its own ([issue #4](https://github.com/GabrielFarfan/csharp-ls-proxy/issues/4)): the child is spawned with `start_new_session` and the final SIGKILL is a `killpg` sweep over its process group, so MSBuild/Roslyn descendant workers are reaped too — and on Linux, `PR_SET_PDEATHSIG(SIGKILL)` makes the real server die the instant the proxy does, even on an uncatchable SIGKILL of the proxy (Linux-only; see the [macOS note](#macos-hard-kill-gap)).

<a id="documentsymbol-range-fix"></a>

## documentSymbol range fix

csharp-ls fills each `DocumentSymbol.range` with the full declaration span
*including leading trivia* — blank lines, `///` doc comments, `[Attribute]`
lines. A client that renders `range.start` (Claude Code's harness does)
shows the symbol a few lines early, and feeding that position into
find-references / go-to-definition lands on a comment or attribute line
and comes back with "No references found".

By default the proxy rewrites every `textDocument/documentSymbol` response
([issue #7](https://github.com/GabrielFarfan/csharp-ls-proxy/issues/7)):
each symbol's `range.start` is collapsed onto its `selectionRange.start`
(recursively through `children`), so the reported position is the
identifier line. `range.end` is untouched, so the range still covers the
whole declaration and `selectionRange` stays contained within `range` as
the LSP spec requires. Only `DocumentSymbol[]` results matched to a
tracked documentSymbol request are rewritten; every other message is
forwarded byte-for-byte.

To forward documentSymbol responses verbatim instead, set
`CSHARP_LS_PROXY_SYMBOL_RANGE_FIX=0` (`false`, `off`, and `no` work too,
case-insensitive). Unset or empty keeps the fix on.

## Requirements

| | Debian / Linux | macOS |
|---|---|---|
| Python 3.11+ on `PATH` (the shebang resolves `python3` via `env`) | `sudo apt install python3` | `brew install python` (or use Apple's `python3` from Xcode CLT) |
| `csharp-ls` installed as a .NET global tool | `dotnet tool install -g csharp-ls` | `dotnet tool install -g csharp-ls` |
| Shell for the optional rc snippet below | zsh (`~/.zshrc`); bash works too (substitute `~/.bashrc`) | zsh (`~/.zshrc`, default since Catalina) |

Both platforms install `csharp-ls` to `~/.dotnet/tools/csharp-ls`, so the
install commands below are identical.

<a id="macos-hard-kill-gap"></a>

> **OS support — one Linux-only protection:** graceful teardown is identical
> on both platforms (stdin-EOF → SIGTERM → SIGKILL escalation over the
> child's process group; Claude Code's force-restart sends a catchable
> SIGTERM, which both OSes handle cleanly). The extra kernel-level guarantee
> that the real server dies even when the proxy itself is **SIGKILL**ed
> (`PR_SET_PDEATHSIG`) is Linux-only — on macOS, a hard SIGKILL of the proxy
> orphans the csharp-ls process tree.

> ⚠️ **csharp-ls version ↔ .NET requirement:** csharp-ls 0.16 runs on .NET 8,
> but 0.17–0.20 target net9.0 and 0.21+ target net10.0. If the `dotnet` on
> your PATH is older than the version you're installing, both the install
> and the runtime fail — in misleading ways. See
> [csharp-ls ≥ 0.17 needs a newer .NET](#csharp-ls-newer-dotnet) before
> updating past 0.16.

## Install

### Quickest — `pip install` (Debian and macOS)

> ⚠️ **PEP 668 heads-up:** if your `python3` is system-managed (apt-installed on modern Debian/Ubuntu, or `brew install python3` on macOS), `pip install --user` will refuse with `externally-managed-environment`. **Skip directly to the [pipx fallback](#pipx-fallback) below** — it's the same flow with `pipx` instead of `pip`. The snippet here works on dev boxes with a user-managed Python (pyenv, asdf, conda, or a manual `python3 -m venv` activated globally).

```sh
set -eu  # fail-fast: pass-26 review correctness — never proceed past a
         # failed pip install into the symlink-swap (or we'd relink to
         # whatever stale csharp-ls-proxy was already on PATH).

# 1) Install and RESOLVE the entry point first. Do NOT touch ~/.dotnet/tools/
#    until we know we have a working proxy to point at — otherwise a failed
#    pip install can leave the box with no csharp-ls at all.
python3 -m pip install --user csharp-ls-proxy
# Pass-27 review correctness #3: probe the sysconfig posix_user scripts dir
# FIRST (where `pip install --user` just wrote the entry point), THEN fall
# back to `command -v`. The opposite order would prefer a stale pipx /
# system-pip install that's earlier on PATH over the fresh user install
# we just performed.
entry="$(python3 -c 'import sysconfig,os; print(os.path.join(sysconfig.get_path("scripts", scheme="posix_user"), "csharp-ls-proxy"))')"
[ -x "$entry" ] || entry="$(command -v csharp-ls-proxy 2>/dev/null || true)"
[ -n "$entry" ] && [ -x "$entry" ] || { echo "csharp-ls-proxy entry point not found or not executable: $entry" >&2; exit 1; }

# 2) Move the real csharp-ls aside ONLY IF .real doesn't already hold a
#    backup. Re-running this block must not overwrite that backup with
#    what's now a symlink to the proxy.
if [ ! -e ~/.dotnet/tools/csharp-ls.real ]; then
    mv  ~/.dotnet/tools/csharp-ls  ~/.dotnet/tools/csharp-ls.real
else
    # Backup already exists -> we're re-running. Verify the CURRENT
    # ~/.dotnet/tools/csharp-ls is the proxy we (or a previous run of
    # this snippet) installed before removing it -- never blow away a
    # binary or symlink the user may have intentionally swapped in.
    # Safe to drop iff it's either (a) absent, (b) a broken symlink,
    # or (c) a symlink whose final target resolves to a csharp-ls-proxy
    # entry point (a pip/pipx-installed `csharp-ls-proxy` console script).
    current="$HOME/.dotnet/tools/csharp-ls"
    if [ ! -e "$current" ] && [ ! -L "$current" ]; then
        : # nothing there, nothing to verify
    else
        # Use python3 realpath — BSD readlink on macOS does not support -f
        # (mirrors install.sh's pattern at install.sh:35-38).
        resolved="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$current" 2>/dev/null || true)"
        case "$(basename "${resolved:-$current}")" in
            # Accept any of our proxy basenames so users migrating
            # from a previous clone install (post- or pre-rename
            # variants of the underscored / hyphenated module file)
            # can swap cleanly to the pip console-script entry without
            # manual intervention. Pass-18 review correctness #2 wired
            # this; pass-27 review correctness #1 reworded so Step 2's
            # grep doesn't spuriously flag this comment.
            csharp-ls-proxy|csharp_ls_proxy.py|csharp-ls-proxy.py) : ;;  # the proxy we expect — safe to replace
            *)
                echo "csharp-ls-proxy install: ~/.dotnet/tools/csharp-ls" >&2
                echo "  resolves to '$resolved' which is NOT our proxy entry point." >&2
                echo "  Refusing to overwrite. Move/delete it manually if you're" >&2
                echo "  sure, then re-run." >&2
                exit 1
                ;;
        esac
    fi
    rm -f "$current"
fi
ln -s "$entry"  ~/.dotnet/tools/csharp-ls
```

Then add the trace-log block to `~/.zshrc` from the [Tracing & log rotation](#tracing--log-rotation) section. Verify the deploy with `csharp-ls-proxy --version` (works as long as `~/.local/bin` is on PATH; otherwise call it via `~/.dotnet/tools/csharp-ls --version`).

<a id="pipx-fallback"></a>

**pipx fallback (required for system-managed Python: macOS Homebrew + modern Debian/Ubuntu):** Both `brew install python3` on macOS AND apt-managed Python on recent Debian/Ubuntu mark the system Python as "externally managed" (PEP 668), so `python3 -m pip install --user` will refuse with an `externally-managed-environment` error. Use [pipx](https://pipx.pypa.io/) instead (pass-16 review correctness #2: same issue on both platforms, not just macOS):

```sh
set -eu  # fail-fast: same rationale as the pip snippet above (pass-26 review).

# Install pipx — runs the right command for your OS automatically (pass-20
# review correctness: a copy-paste-friendly snippet, not a "pick a line"
# instruction that left brew install pipx as the executable line for Debian).
if   command -v brew    >/dev/null 2>&1; then brew install pipx
elif command -v apt-get >/dev/null 2>&1; then sudo apt-get install -y pipx
else
    echo "Install pipx for your OS, then re-run this snippet." >&2
    echo "  https://pipx.pypa.io/stable/installation/" >&2
    exit 1
fi
pipx install csharp-ls-proxy
entry="$(pipx environment --value PIPX_BIN_DIR)/csharp-ls-proxy"
[ -x "$entry" ] || { echo "pipx install did not produce: $entry" >&2; exit 1; }
if [ ! -e ~/.dotnet/tools/csharp-ls.real ]; then
    mv  ~/.dotnet/tools/csharp-ls  ~/.dotnet/tools/csharp-ls.real
else
    # Same safety check as the pip-install snippet above: only drop the
    # current entry if it's our proxy (or absent / broken symlink).
    current="$HOME/.dotnet/tools/csharp-ls"
    if [ ! -e "$current" ] && [ ! -L "$current" ]; then
        :
    else
        # python3 realpath — BSD readlink on macOS lacks -f (install.sh:35-38).
        resolved="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$current" 2>/dev/null || true)"
        case "$(basename "${resolved:-$current}")" in
            # Pass-18 review correctness #2: accept clone-install basenames too.
            csharp-ls-proxy|csharp_ls_proxy.py|csharp-ls-proxy.py) : ;;
            *)
                echo "csharp-ls-proxy install: refusing to overwrite $current (resolves to '$resolved')" >&2
                exit 1
                ;;
        esac
    fi
    rm -f "$current"
fi
ln -s "$entry"  ~/.dotnet/tools/csharp-ls
```

Or, if you'd rather skip the Python packaging dance entirely, use the clone-and-run path below.

### Quick — clone-and-run, Debian and macOS

PyPI is the public distribution channel — the `pip`/`pipx` paths above work for everyone. The GitHub repo itself requires repo access, so the clone-based paths (this one and [Manual](#manual) below) only work for accounts that have it; the install authenticates via the [GitHub CLI](https://cli.github.com/) (`gh`). One-time, on a fresh machine:

```sh
# Debian
sudo apt install gh && gh auth login

# macOS
brew install gh && gh auth login
```

Then clone + run the installer (one command, idempotent — safe to re-run):

```sh
gh repo clone GabrielFarfan/csharp-ls-proxy ~/csharp-ls-proxy && ~/csharp-ls-proxy/install.sh
```

The installer swaps the proxy in front of `csharp-ls`, appends the per-shell
trace-log + rotation block to your shell's rc file (`~/.zshrc` for zsh,
`~/.bashrc` for bash; skipped with a note — never created — otherwise), and
follows the full symlink chain — a pre-existing multi-hop install is
recognized and left alone. It finishes with a `--version` smoke test through
the swapped symlink. Override the clone destination with
`CSHARP_LS_PROXY_REPO=/path` before running it.

### Manual

Same repo-access caveat as the clone-and-run path above: an anonymous
`git clone https://github.com/GabrielFarfan/csharp-ls-proxy` 404s for
non-collaborators, so authenticate `gh` first (or use git credentials
that have repo access). No access? Use the [pip/pipx path](#install).

```sh
gh repo clone GabrielFarfan/csharp-ls-proxy ~/csharp-ls-proxy
chmod +x ~/csharp-ls-proxy/csharp_ls_proxy.py    # belt-and-suspenders; git tracks +x

mv  ~/.dotnet/tools/csharp-ls  ~/.dotnet/tools/csharp-ls.real
ln -s ~/csharp-ls-proxy/csharp_ls_proxy.py  ~/.dotnet/tools/csharp-ls
```

The proxy looks up the real binary via `CSHARP_LS_REAL_BIN`, defaulting to
`~/.dotnet/tools/csharp-ls.real`; set the env var if your `csharp-ls` lives
elsewhere.

If you'd rather expose the proxy under `~/.local/bin/` too (e.g. to share it
with other tooling), use an extra hop:

```sh
ln -s ~/csharp-ls-proxy/csharp_ls_proxy.py  ~/.local/bin/csharp_ls_proxy.py
ln -s ~/.local/bin/csharp_ls_proxy.py       ~/.dotnet/tools/csharp-ls
```

<a id="csharp-ls-newer-dotnet"></a>

### csharp-ls ≥ 0.17 needs a newer .NET than your default `dotnet` may have

csharp-ls 0.17–0.20 target net9.0 and 0.21+ target net10.0
([issue #14](https://github.com/GabrielFarfan/csharp-ls-proxy/issues/14)).
Two traps when the `dotnet` on your PATH is older (e.g. a system-wide
.NET 8 under `/usr/share/dotnet`):

- **Updating:** `dotnet tool update -g csharp-ls` fails with a *misleading*
  error — `The settings file in the tool's NuGet package is invalid:
  Settings file 'DotnetToolSettings.xml' was not found in the package.`
  The real cause is an SDK too old to read the package's net10.0 assets.
  Run the update with a matching SDK instead (e.g. a user-local install
  from [dotnet-install](https://dot.net/v1/dotnet-install.sh)):

  ```sh
  DOTNET_ROOT="$HOME/.dotnet" "$HOME/.dotnet/dotnet" tool update -g csharp-ls
  ~/csharp-ls-proxy/install.sh   # rotate the new launcher into csharp-ls.real
  ```

- **Running:** the tool launcher (now parked at `csharp-ls.real`) probes
  `DOTNET_ROOT` and the machine-default install location for a net10
  runtime; a user-local `~/.dotnet` is **not** probed automatically, so it
  dies with `You must install or update .NET to run this application.`
  And a runtime alone is not enough: with no matching **SDK** visible,
  the solution loads but `textDocument/references` fails 100% of the time
  with `-32603 Internal error: AggregateException` (inner:
  `Unexpected value 'Microsoft.CodeAnalysis.Diagnostics.UnresolvedAnalyzerReference'`).

Don't fix this by exporting `DOTNET_ROOT` globally — that redirects runtime
resolution for *every* apphost binary you run (your net8.0 apps would stop
finding the .NET 8 runtime). Scope it to csharp-ls with a wrapper behind the
`CSHARP_LS_REAL_BIN` hook the proxy already honors:

```sh
#!/bin/sh
# ~/.local/bin/csharp-ls-net10  (chmod +x)
export DOTNET_ROOT="$HOME/.dotnet"   # the .NET 10 SDK install
export PATH="$HOME/.dotnet:$PATH"    # so csharp-ls's SDK locator registers it
exec "$HOME/.dotnet/tools/csharp-ls.real" "$@"
```

```sh
# ~/.zshrc (new shells only)
export CSHARP_LS_REAL_BIN="$HOME/.local/bin/csharp-ls-net10"
```

Future `dotnet tool update` runs keep working unchanged: `install.sh`
rotates each new launcher into `csharp-ls.real` and the wrapper picks it
up from there.

## Tracing & log rotation

Set `CSHARP_LS_PROXY_LOG=/path/to/trace.log` to record a compact trace of every
LSP message that crosses the proxy. The proxy auto-suffixes its own PID
before the file extension, so concurrent csharp-ls instances under one
shell land in distinct files (`$$.<proxy-pid>.log` rather than collapsing
to `$$.log`). For per-LSP-instance logs with automatic age-based rotation,
append this block to **`~/.zshrc`** (or `~/.bashrc` on bash). It works
identically on Debian and macOS — `find -maxdepth`, `-mtime`, and `-delete`
are supported by both GNU find (Debian) and BSD find (macOS):

```sh
# csharp-ls proxy: $$ is the shell PID; the proxy auto-suffixes its own
# PID before .log, so multi-workspace shells get distinct per-LSP files
# (e.g. /home/u/.cache/csharp-ls-proxy/12345.67890.log).
# Rotation: prune *.log files untouched for 14+ days at shell start. The active
# log's mtime updates with each write, so age-based pruning never deletes a
# live trace -- only stale logs from already-closed shells age out.
mkdir -p "$HOME/.cache/csharp-ls-proxy" 2>/dev/null
find "$HOME/.cache/csharp-ls-proxy" -maxdepth 1 -name '*.log' -mtime +14 -delete 2>/dev/null
export CSHARP_LS_PROXY_LOG="$HOME/.cache/csharp-ls-proxy/$$.log"
```

Takes effect on new shells only — restart `claude` from a fresh terminal so
its `csharp-ls` child inherits the env var. Tune the retention window by
changing the `+14` in the `find` line.

## Concurrent use (multiple sessions / projects)

Each invocation of the proxy is fully independent: a separate Python process
with its own `csharp-ls.real` child and its own stdio pipes. Multiple Claude
Code sessions in different directories, against different C# projects, all
coexist without sharing anything beyond the binary on disk. The cost is one
Roslyn solution load per session (a `csharp-ls` cost, not a proxy cost).

### Subagents (Claude Code caveat, not a proxy limitation)

The proxy itself is consumer-agnostic — any process that runs `csharp-ls`
goes through it. **However**, as of 2026-05-28 Claude Code's built-in `LSP`
tool is gated on `enabledPlugins` in user settings and is **not propagated
to spawned subagents** (in-process `Agent` calls, `TeamCreate` members, etc.).
The parent session sees the tool; subagents get `No matching deferred tools
found` when they try to look it up. This is upstream issue
[anthropics/claude-code#61210](https://github.com/anthropics/claude-code/issues/61210)
and the architecture is the same for every `*-lsp@claude-plugins-official`
plugin (`csharp-lsp`, `jdtls-lsp`, `rust-analyzer-lsp`, etc.) — they're bare
markers with no `.mcp.json`/`plugin.json`, so the harness never includes them
in subagent tool environments.

Practical implication: today a subagent can't reach the proxy via the LSP
tool — not because the proxy is wrong, but because the tool itself isn't
available there. The validated workaround (per the issue's comments) is to
wrap the LSP in an MCP server (e.g. `lsp-mcp-server` on npm) and register it
as a normal MCP server; MCP-tool propagation does work across subagents. This
proxy keeps working unchanged in front of that wrapped LSP.

When (if) the upstream fix lands — re-reading `enabledPlugins` when
constructing subagent tool environments — subagents will get the LSP tool
and route through this proxy automatically. Nothing in this repo needs to
change.

## Tests

```sh
python3 -m unittest discover -s tests -v
```

Stdlib `unittest` only, no third-party deps. Covers `read_frame` malformed-
input handling, the dispatch guard and local-response shapes, the decline
warnings, and integration tests against fake servers (broken-pipe, final-
message drain, SIGTERM-ignoring child, malformed frame).

## Known limitations

### Cold-start: first C# LSP call in a session (csharp-ls ≤ 0.20)

When Claude Code opens its first `.cs` file in a session, it fires
`textDocument/documentSymbol` to `csharp-ls` **immediately** — before
Roslyn has loaded the solution (~10s on a fresh project). csharp-ls
0.16–0.20 answers such pre-load queries `null`/empty, and **Claude
Code's LSP client does not retry after indexing finishes**, so the
user would see:

> No symbols found in document. This may occur if the file is empty,
> not supported by the LSP server, or if the server has not fully
> indexed the file.

**Since v0.3.0 the proxy masks this race**
([issue #2](https://github.com/GabrielFarfan/csharp-ls-proxy/issues/2)):
it suppresses the empty pre-load answer and replays the query once
`csharp-ls` signals load-complete (the `$/progress` end of the load
token — identified by its begin title,
[issue #12](https://github.com/GabrielFarfan/csharp-ls-proxy/issues/12)).
A watchdog (`CSHARP_LS_PROXY_LOAD_TIMEOUT`, default 30s) forwards the
suppressed response as-is if no load signal ever arrives.

**csharp-ls ≥ 0.21 makes both the race and the mask moot** (verified
on 0.24.0 against real Claude Code sessions,
[issue #14](https://github.com/GabrielFarfan/csharp-ls-proxy/issues/14)):
the server holds pre-load `textDocument/*` requests itself and answers
them with full results once the load completes. With Claude Code's
real client — which does not advertise `window.workDoneProgress` —
0.24 emits no `$/progress` at all, so the proxy's replay machinery
never arms. It remains in place as a fallback for the older servers.
When the load-timeout watchdog fires having seen no progress signal
at all, the proxy logs a one-line stderr notice naming this mode, so
it shows up in traces instead of looking like a silent stall.

### `csharp-ls --help` through the proxy exits silently

If you invoke `csharp-ls --help` through the proxy, the underlying
`csharp-ls.real` prints its help text to stdout and exits. The proxy's
server-to-client pump only forwards successfully-framed LSP messages, so
the plaintext help output is **not** forwarded — you'll see an empty
stdout and a clean rc=0. For interactive help, run `csharp-ls.real --help`
directly (the proxy is only meant to sit between an LSP client and
`csharp-ls`).

Note that `csharp-ls-proxy --version` is **not** affected — the proxy
owns and serves `--version` itself (printing its own version + diagnostic
config block, see `csharp-ls-proxy --version`), so it never reaches the
backend at all.

### SIGKILL of the proxy still orphans grandchild workers (even on Linux)

`PR_SET_PDEATHSIG` covers only the **direct** `csharp-ls.real` child: when
the proxy is SIGKILLed, the kernel SIGKILLs csharp-ls — which therefore
cannot clean up its own MSBuild/Roslyn worker processes, and no proxy code
runs to sweep the group (SIGKILL is uncatchable). Those grandchildren are
left to exit on their own. Catchable signals — SIGTERM/SIGHUP/SIGINT,
including what Claude Code's force-restart sends — do sweep the whole
process group, so this gap only opens on a hard `kill -9` of the proxy.
(On macOS even the direct child is orphaned — see the
[macOS note](#macos-hard-kill-gap).)

### A client that stops reading stdout can wedge the proxy

Writes to the client's stdout have no deadline. If the client process stays
alive but stops draining the proxy's stdout, the server→client pump blocks
in the write indefinitely and the session wedges. An external SIGTERM
recovers cleanly (the signal handler kills the child group and exits) —
and Claude Code's force-restart sends exactly that — but the proxy itself
never times the write out.

### Theoretical PID-reuse window in the final `killpg` sweep

Teardown always force-kills the child's process group last, using the
group id captured at spawn (it cannot be re-derived later — the group
leader may already be reaped). If the *entire* group has already exited
and been reaped, the kernel could in principle reuse that id for an
unrelated process group before the sweep fires. This is a deliberate
tradeoff: the sweep is what reaps SIGTERM-ignoring MSBuild/Roslyn workers,
and the window is vanishingly small in practice.

## Prior art

Other solutions that fix or work around the same Claude Code ↔ `csharp-ls` bug — pick what fits your stack:

| Project | Lang | Architecture | Notes |
|---|---|---|---|
| [Agasper/CSharpLspAdapter](https://github.com/Agasper/CSharpLspAdapter) | C# (.NET tool, NuGet) | stdio proxy | Closest twin. Intercepts the same three core requests. Installs via `dotnet tool install -g CSharpLspAdapter`. |
| [fawques/csharp-lsp-claude](https://github.com/fawques/csharp-lsp-claude) | C# (.NET 10) | stdio proxy + warmup race | Races first real request against a 3-second deadline to avoid cold-start timeouts. This repo masks the same race via suppress-and-replay (since v0.3.0, see Known limitations). |
| [Tritlo/lsp-mcp](https://github.com/Tritlo/lsp-mcp) | TypeScript | LSP-as-MCP server | Different architecture: wraps any LSP server as an MCP server. The upstream-blessed workaround for [anthropics/claude-code#61210](https://github.com/anthropics/claude-code/issues/61210) (LSP-tool propagation to subagents). |

Compared to those, this proxy is stdlib-only Python with zero runtime dependencies, intercepts seven server-initiated requests rather than three (adds `workspace/applyEdit`, `window/showDocument`, `window/showMessageRequest`, `workspace/workspaceFolders`), adds robustness hardening (`FrameError`, header + body caps, daemon pump + bounded-join drain), and adds orphan protection ([issue #4](https://github.com/GabrielFarfan/csharp-ls-proxy/issues/4)): teardown escalates stdin-EOF → SIGTERM → SIGKILL over the child's process group (`start_new_session` + a final `killpg` sweep, so MSBuild/Roslyn descendant workers are reaped too), and on Linux `PR_SET_PDEATHSIG(SIGKILL)` kills the real server the instant the proxy dies — even on an uncatchable SIGKILL of the proxy.

## Uninstall

```sh
# 1) Reverse the symlink swap (all install paths)
rm  ~/.dotnet/tools/csharp-ls
mv  ~/.dotnet/tools/csharp-ls.real  ~/.dotnet/tools/csharp-ls

# 2) Remove the package — pick the one matching how you installed:
python3 -m pip uninstall csharp-ls-proxy   # pip path
pipx uninstall csharp-ls-proxy             # pipx fallback
# (clone-based installs: just delete the clone, e.g. rm -rf ~/csharp-ls-proxy)
```

Then tidy up the optional pieces, if you added them:

- remove `~/.local/bin/csharp_ls_proxy.py` if you used the two-hop manual install;
- remove `~/.local/bin/csharp-ls-net10` if you created the .NET 10 wrapper
  (see [csharp-ls ≥ 0.17 needs a newer .NET](#csharp-ls-newer-dotnet)), and
  drop the `export CSHARP_LS_REAL_BIN=...` line from `~/.zshrc` / `~/.bashrc`;
- drop the `CSHARP_LS_PROXY_LOG` trace-log + rotation block from
  `~/.zshrc` / `~/.bashrc`.
