Skip to content

fix(lsp): admit acknowledgement receipts off the session lock - #4859

Open
ScriptedAlchemy wants to merge 1 commit into
masterfrom
fix/lsp-ack-receipt-admission
Open

ScriptedAlchemy wants to merge 1 commit into
masterfrom
fix/lsp-ack-receipt-admission

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

Problem

production_lsp_negotiates_and_projects_canonical_context failed on Linux with acknowledge daemon LSP frame: DeadlineExceeded (PR #4828, run 42724276448, job 129532080196, runtime_acceptance_suite/test.log: 140 passed / 1 failed, suite 126 s). Poll and send had already succeeded on the same connection.

The acknowledgement is the only LSP invocation that does durable disk I/O on the request path. acknowledge_lsp_frame called BoundedDeliverySettlementRecorderV1::try_record inline, which publishes the receipt to the spool (file fsync, rename, directory fsync). It did so on the serving thread and while holding the daemon-wide lsp_sessions lock. Every daemon invocation takes that lock to expire sessions, so one acknowledgement's fsyncs stalled every other request behind it. detach had the same inline admission.

Measurement

TRACEDECAY_SPAN_TIMINGS=1 with the daemon trace-scoped to the LSP and private-fs targets, on the production LSP journey (idle macOS, current master):

span busy idle
daemon.service.lsp.poll ~160 µs ~2 µs
daemon.service.lsp.acknowledge before 8–13 ms (max 13.4) ~2 µs
daemon.service.lsp.acknowledge after 25–50 µs 7–17 ms (await on the blocking pool, no lock held)

Before, essentially all of the acknowledge span was private_fs.framed_log.publish (two fsyncs, 3–6 ms each). Client-observed acknowledgement latency is unchanged (7–18 ms) because the receipt is still durable before the acknowledgement is answered.

Change

Claim the in-flight attempt under the session lock, release it, then admit the receipt on the blocking pool (native_runtime::spawn_blocking, the pattern the scope-set compare-and-swap already uses). Applies to acknowledge and detach. The attempt leaves the session when claimed, so it cannot be settled twice, and the work is queued at the call, so a dropped connection abandons only the wait. Drop keeps inline admission because it cannot await. No timeout, retry, or durability change.

Evidence

  • New lsp_acknowledgement_admits_its_delivery_receipt_off_the_serving_thread drives the real service, real recorder, real spool and global DB, and captures the thread that opens private_fs.framed_log.publish. It fails on the unfixed code ("the acknowledgement published its receipt on the serving thread") and passes with the fix. It also asserts the receipt settles durably as one Delivered. The existing detach and disconnect-expiry drop tests still pass.
  • //crates/tracedecay-daemon-service:unit_test and :invocation_observability pass; //:rustfmt_check and --config=clippy on the crate are clean.
  • The production LSP journey passes with the change.

Not established

This is not a reproduction of the Linux failure. Current master passes the journey on macOS, and the full runtime_acceptance_suite also passes locally (141/141, max acknowledgement 17 ms), so no fsync amplification appeared on this disk. The Linux failure is consistent with fsync stalls under the 141-test parallel suite, but that is not proven. If a single acknowledgement's own fsyncs still exceed the client's 5 s deadline on a stalled disk, this change does not shorten that wait; it removes the worker and global-lock amplification. The macOS documentSymbolProvider initialize failures logged on #4834 are a separate negotiation/readiness symptom and are not touched here.

Other synchronous try_record callers (Work delivery ACK in connection_serving, tracedecay-mcp rmcp) are untouched.

🤖 Generated with Claude Code


Devin Review

An LSP frame acknowledgement published its delivery receipt to the
recorder's spool (file and directory fsyncs) inline, on the serving
thread, while holding the daemon-wide LSP session map lock. Every
invocation takes that lock to expire sessions, so each acknowledgement's
fsyncs serialized every daemon request behind them and parked a serving
worker.

Measured with TRACEDECAY_SPAN_TIMINGS on the production LSP journey
(runtime_surface_acceptance::production_lsp_negotiates_and_projects_
canonical_context, idle macOS), the acknowledge span was 8-13 ms busy
with ~2 us idle, all of it in private_fs.framed_log.publish, against
~160 us for a poll. After the change it is 25-50 us busy; the wait is an
await on the blocking pool with no lock held. The receipt is still
durable before the acknowledgement is answered.

Claim the in-flight attempt under the lock and admit it afterwards on
the blocking pool, for acknowledgement and detach. Drop keeps inline
admission because it cannot await.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Nov 10, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f528034

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

Comment on lines +799 to +800
if let Some(delivery) = delivery {
let _ = delivery.admit().await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Acknowledgement receipts lost during shutdown

If shutdown follows an acknowledgement's claim, delivery.admit() can run after the recorder closes. The claimed attempt has left its session, so the delivered receipt is lost.

Learn more

An LSP acknowledgement takes the in-flight attempt before queuing its durable receipt. expire_all clears sessions before stopping project runtimes. Recorder shutdown changes the recorder to STOPPING, and try_record then refuses new receipts. The new await allows shutdown to pass the session clear while a claimed receipt still waits on the blocking pool. The attempt is no longer in the actor, so its drop fallback cannot recover it.

Example: An ACK claims frame 7, and its blocking task waits in the pool. Shutdown clears the session and drains its recorder. The task then tries to admit frame 7 and gets delivery_settlement_recorder_closed; no Delivered observation exists.

Recommended fix: Track claimed-but-unadmitted receipts as invocation-owned work and join them before project_runtimes.shut_down_all closes recorders. Ensure project retirement also fences or joins claims before shutting its store recorder.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +839 to +843
if let Ok(delivery) = session.claim_in_flight_delivery(
tracedecay_domain::DeliverySettlementOutcomeV1::Dropped,
Some(tracedecay_domain::DeliveryDropReasonV1::Disconnected),
);
) {
let _ = delivery.admit().await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Cancelled detach retains orphaned lease

If a detached session has a lease, cancelling delivery.admit() skips the later lease cancellation. The session is already removed, but its lease task remains active until expiry.

Learn more

A disconnected session has a timed lease task registered by disconnect_lsp_session. A later detach closes the endpoint and removes the actor before waiting for the newly queued receipt. If the request future is dropped while waiting, the work still records the receipt, but the next lease cancellation never executes. The expiry task remains in LspLeaseTaskRegistry until the original lease deadline.

Example: A client disconnects with an outbound frame pending, then sends an explicit detach with the same credential. The detach queues a slow receipt write; its request times out. The session is gone, but its disconnected-session lease continues waiting.

Recommended fix: Cancel the lease before awaiting receipt admission, or move lease cleanup into an owned guard that survives cancellation after session removal. Preserve receipt admission even if the client stops waiting.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant