Repository navigation
fix(lsp): admit acknowledgement receipts off the session lock #4859
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -773,19 +773,31 @@ impl DaemonInvocationService { | |
| Ok(access) => access, | ||
| Err(problem) => return DaemonInvocationResponse::problem(request_id, problem), | ||
| }; | ||
| let mut sessions = self.lsp_sessions.lock().await; | ||
| let Some(session) = sessions.get_mut(access.session_id()) else { | ||
| return DaemonInvocationResponse::problem( | ||
| request_id, | ||
| DaemonInvocationProblem::NotFoundOrNotAuthorized, | ||
| ); | ||
| // The session map lock covers only the state transition. The receipt's | ||
| // durable admission is disk-latency work and runs after it releases. | ||
| let (acknowledged, delivery) = { | ||
| let mut sessions = self.lsp_sessions.lock().await; | ||
| let Some(session) = sessions.get_mut(access.session_id()) else { | ||
| return DaemonInvocationResponse::problem( | ||
| request_id, | ||
| DaemonInvocationProblem::NotFoundOrNotAuthorized, | ||
| ); | ||
| }; | ||
| let acknowledged = session.actor.acknowledge_outbound(); | ||
| let delivery = if acknowledged { | ||
| session | ||
| .claim_in_flight_delivery( | ||
| tracedecay_domain::DeliverySettlementOutcomeV1::Delivered, | ||
| None, | ||
| ) | ||
| .ok() | ||
| } else { | ||
| None | ||
| }; | ||
| (acknowledged, delivery) | ||
| }; | ||
| let acknowledged = session.actor.acknowledge_outbound(); | ||
| if acknowledged { | ||
| let _ = session.settle_in_flight_delivery( | ||
| tracedecay_domain::DeliverySettlementOutcomeV1::Delivered, | ||
| None, | ||
| ); | ||
| if let Some(delivery) = delivery { | ||
| let _ = delivery.admit().await; | ||
| } | ||
| DaemonInvocationResponse::with_outcome( | ||
| request_id, | ||
|
|
@@ -824,10 +836,12 @@ impl DaemonInvocationService { | |
| // The stdio bridge reaches this path after a write or flush failure. | ||
| // Removing the actor discards its in-flight frame, so terminalize that | ||
| // exact captured attempt before any later detach bookkeeping can fail. | ||
| let _ = session.settle_in_flight_delivery( | ||
| if let Ok(delivery) = session.claim_in_flight_delivery( | ||
| tracedecay_domain::DeliverySettlementOutcomeV1::Dropped, | ||
| Some(tracedecay_domain::DeliveryDropReasonV1::Disconnected), | ||
| ); | ||
| ) { | ||
| let _ = delivery.admit().await; | ||
|
Comment on lines
+839
to
+843
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Learn moreA 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. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
| let lease_cancelled = self | ||
| .lsp_lease_tasks | ||
| .cancel(access.session_id()) | ||
|
|
||
There was a problem hiding this comment.
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_allcloses recorders. Ensure project retirement also fences or joins claims before shutting its store recorder.Was this helpful? React with 👍 or 👎 to provide feedback.