diff --git a/Beam/Broker/Client.lean b/Beam/Broker/Client.lean index 983a9777..10b35062 100644 --- a/Beam/Broker/Client.lean +++ b/Beam/Broker/Client.lean @@ -19,6 +19,48 @@ structure StreamCallbacks where abbrev Endpoint := Transport.Endpoint +/-- The transport operation that produced a typed broker client failure. -/ +inductive BrokerTransportOperation where + | connect + | send + | receive + deriving Repr, BEq + +private def BrokerTransportOperation.label : BrokerTransportOperation → String + | .connect => "connect" + | .send => "send" + | .receive => "receive" + +/-- Keep broker client failures typed until a CLI or transport presentation boundary. -/ +inductive BrokerClientFailure where + | transport (operation : BrokerTransportOperation) (error : IO.Error) + | invalidResponse (detail : String) + | streamCallback (error : IO.Error) + | responseTimeout (timeoutMs : Nat) + +def BrokerClientFailure.detail : BrokerClientFailure → String + | .transport operation error => + s!"Beam daemon {operation.label} failed: {error}" + | .streamCallback error => error.toString + | .invalidResponse detail => detail + | .responseTimeout timeoutMs => + s!"Beam daemon response timed out after {timeoutMs} ms" + +instance : Repr BrokerClientFailure where + reprPrec failure _ := Std.Format.text <| + match failure with + | .transport operation error => s!"BrokerClientFailure.transport {repr operation} {error}" + | .invalidResponse detail => s!"BrokerClientFailure.invalidResponse {detail}" + | .streamCallback error => s!"BrokerClientFailure.streamCallback {error}" + | .responseTimeout timeoutMs => s!"BrokerClientFailure.responseTimeout {timeoutMs}" + +private def BrokerClientFailure.toIOError : BrokerClientFailure → IO.Error + | failure@(.transport ..) => IO.userError failure.detail + | .streamCallback error => error + | .invalidResponse detail => IO.userError detail + | .responseTimeout timeoutMs => + IO.userError s!"Beam daemon response timed out after {timeoutMs} ms" + def parsePortText (name value : String) : Except String UInt16 := do let some n := value.toNat? | throw s!"invalid {name} '{value}'" @@ -34,13 +76,21 @@ def parseEndpointOption (args : List String) : Except String (Endpoint × List S | _ => pure (.tcp 8765, args) -private def decodeStreamMessage (msg : String) : IO StreamMessage := do +private def decodeStreamMessage (msg : String) : Except String StreamMessage := do match Json.parse msg with - | .error err => throw <| IO.userError s!"invalid Beam daemon response json: {err}" + | .error err => throw s!"invalid Beam daemon response json: {err}" | .ok json => match fromJson? (α := StreamMessage) json with | .ok stream => pure stream - | .error err => throw <| IO.userError s!"invalid Beam daemon response payload: {err}" + | .error err => throw s!"invalid Beam daemon response payload: {err}" + +private def captureClientFailure + (failure : IO.Error → BrokerClientFailure) + (action : IO α) : IO (Except BrokerClientFailure α) := do + try + pure <| .ok (← action) + catch e => + pure <| .error (failure e) private def diagnosticSeverityLabel : Option Lsp.DiagnosticSeverity → String | some .error => "error" @@ -67,34 +117,91 @@ def formatStreamDiagnostic (diagnostic : StreamDiagnostic) : String := "" s!"beam: diagnostic {severity}{blocking} {diagnostic.path}:{line}:{character}: {message}" -partial def sendRequestWithStream +private structure ResponseDeadline where + timeoutMs : Nat + deadlineNanos : Nat + +/-- Send one request while preserving transport, response, callback, and timeout failures. -/ +private partial def sendRequestWithStreamResultCore (endpoint : Endpoint) (req : Request) - (onStream : StreamMessage → IO Unit) : IO Response := do - let client ← Transport.connect endpoint + (onStream : StreamMessage → IO Unit) + (responseTimeoutMs? : Option Nat) : IO (Except BrokerClientFailure Response) := do + let client ← + match ← captureClientFailure (.transport .connect) (Transport.connect endpoint) with + | .ok client => pure client + | .error failure => return .error failure try - Transport.sendMsg client (toJson req).compress - let rec loop : IO Response := do - let msg ← Transport.recvMsg client - let stream ← decodeStreamMessage msg + match ← captureClientFailure (.transport .send) <| + Transport.sendMsg client (toJson req).compress with + | .ok () => pure () + | .error failure => return .error failure + let deadline? : Option ResponseDeadline ← responseTimeoutMs?.mapM fun timeoutMs => do + pure { + timeoutMs + deadlineNanos := (← IO.monoNanosNow) + timeoutMs * 1000000 + } + let rec loop : IO (Except BrokerClientFailure Response) := do + let msg ← + match deadline? with + | none => + match ← captureClientFailure (.transport .receive) (Transport.recvMsg client) with + | .ok msg => pure msg + | .error failure => return .error failure + | some deadline => + match ← captureClientFailure (.transport .receive) <| + Transport.recvMsgUntil client deadline.deadlineNanos with + | .ok (some msg) => pure msg + | .ok none => return .error (.responseTimeout deadline.timeoutMs) + | .error failure => return .error failure + let stream ← + match decodeStreamMessage msg with + | .ok stream => pure stream + | .error detail => return .error (.invalidResponse detail) unless stream.clientRequestId? == req.clientRequestId? do - throw <| IO.userError + return .error <| .invalidResponse <| s!"Beam daemon stream request id {stream.clientRequestId?} does not match request id {req.clientRequestId?}" - onStream stream + match ← captureClientFailure .streamCallback (onStream stream) with + | .ok () => pure () + | .error failure => return .error failure match stream with | .response _ response => - pure response + pure <| .ok response | .fileProgress .. | .diagnostic .. => loop loop finally Transport.closeConnection client -partial def sendRequestWithCallbacks +/-- Send one request while preserving transport, response, and callback failures as typed data. -/ +partial def sendRequestWithStreamResult (endpoint : Endpoint) (req : Request) - (callbacks : StreamCallbacks := {}) : IO Response := do - sendRequestWithStream endpoint req fun stream => do + (onStream : StreamMessage → IO Unit) : IO (Except BrokerClientFailure Response) := do + sendRequestWithStreamResultCore endpoint req onStream none + +/-- Send one request with an absolute timeout for receiving its complete response stream. -/ +partial def sendRequestWithStreamTimeoutResult + (endpoint : Endpoint) + (req : Request) + (timeoutMs : Nat) + (onStream : StreamMessage → IO Unit) : IO (Except BrokerClientFailure Response) := do + sendRequestWithStreamResultCore endpoint req onStream (some timeoutMs) + +partial def sendRequestWithStream + (endpoint : Endpoint) + (req : Request) + (onStream : StreamMessage → IO Unit) : IO Response := do + match ← sendRequestWithStreamResult endpoint req onStream with + | .ok response => pure response + | .error failure => throw failure.toIOError + +/-- Send one request with typed client failures and structured progress callbacks. -/ +partial def sendRequestWithCallbacksResult + (endpoint : Endpoint) + (req : Request) + (callbacks : StreamCallbacks := {}) : IO (Except BrokerClientFailure Response) := do + sendRequestWithStreamResult endpoint req fun stream => do match stream with | .response .. => pure () @@ -102,6 +209,14 @@ partial def sendRequestWithCallbacks callbacks.onFileProgress clientRequestId? progress | .diagnostic clientRequestId? diagnostic => callbacks.onDiagnostic clientRequestId? diagnostic + +partial def sendRequestWithCallbacks + (endpoint : Endpoint) + (req : Request) + (callbacks : StreamCallbacks := {}) : IO Response := do + match ← sendRequestWithCallbacksResult endpoint req callbacks with + | .ok response => pure response + | .error failure => throw failure.toIOError def sendRequest (endpoint : Endpoint) (req : Request) : IO Response := sendRequestWithCallbacks endpoint req diff --git a/Beam/Broker/Pending.lean b/Beam/Broker/Pending.lean index c665646c..4051a5b7 100644 --- a/Beam/Broker/Pending.lean +++ b/Beam/Broker/Pending.lean @@ -96,11 +96,34 @@ def resolveError catch _ => pure () -def awaitOutcome (promise : IO.Promise (Except ResponseFailure PendingResult)) : - IO (Except ResponseFailure PendingResult) := do - let some result ← IO.wait promise.result? +/-- Give an already-marked broker cancellation precedence over a concurrent backend failure. -/ +private def failureRespectingCancellation + (cancelRef? : Option (IO.Ref Bool)) + (fallback : ResponseFailure) : IO ResponseFailure := do + let cancelled ← + match cancelRef? with + | some cancelRef => cancelRef.get + | none => pure false + if cancelled then + pure <| + (responseFailureFor .requestCancelled + "request was cancelled before its backend failure was observed") + |>.withOptionalFileProgress fallback.fileProgress? + else + pure fallback + +/-- +Await the pending request, giving its already-marked cancellation identity precedence over a +concurrent backend failure while preserving observations attached to that failure. A completed +backend success remains successful. +-/ +def awaitOutcome (pending : PendingRequest) : IO (Except ResponseFailure PendingResult) := do + let some outcome ← IO.wait pending.promise.result? | throw <| IO.userError "pending broker request promise dropped" - pure result + match outcome with + | .ok result => pure (.ok result) + | .error failure => + pure (.error (← failureRespectingCancellation pending.cancelRef? failure)) private def normalizePublishDiagnostics (params : PublishDiagnosticsParams) : PublishDiagnosticsParams := { @@ -318,45 +341,91 @@ def propagateCancellation end PendingRequestStore -structure ActiveRequest where +private structure ActiveRequestKey where + workspaceId? : Option WorkspaceId clientRequestId : String +deriving BEq, Ord + +structure ActiveRequest where + workspaceId? : Option WorkspaceId + clientRequestId? : Option String token : Nat cancelRef : IO.Ref Bool private structure ActiveRequestRegistryState where nextToken : Nat := 1 - requests : Std.TreeMap String ActiveRequest := {} + accepting : Bool := true + requests : Std.TreeMap ActiveRequestKey ActiveRequest := {} + anonymousRequests : Std.TreeMap Nat ActiveRequest := {} + drainedSignaled : Bool := false structure ActiveRequestRegistry where private mutex : Std.Mutex ActiveRequestRegistryState + private drained : IO.Promise Unit namespace ActiveRequestRegistry def create : BaseIO ActiveRequestRegistry := do - pure { mutex := ← Std.Mutex.new {} } + pure { + mutex := ← Std.Mutex.new {} + drained := ← IO.Promise.new + } + +private def activeRequestCount + (state : ActiveRequestRegistryState) : Nat := + state.requests.size + state.anonymousRequests.size + +private def markDrainedIfReady + (state : ActiveRequestRegistryState) : ActiveRequestRegistryState × Bool := + if !state.accepting && activeRequestCount state == 0 && !state.drainedSignaled then + ({ state with drainedSignaled := true }, true) + else + (state, false) + +private def resolveDrainedIfNeeded + (registry : ActiveRequestRegistry) + (shouldResolve : Bool) : IO Unit := do + if shouldResolve then + registry.drained.resolve () def register (registry : ActiveRequestRegistry) - (clientRequestId? : Option String) : IO (Except BrokerFailure (Option ActiveRequest)) := do - match clientRequestId? with - | none => - pure (.ok none) - | some clientRequestId => - let cancelRef ← IO.mkRef false - registry.mutex.atomically do - let state ← get - if state.requests.contains clientRequestId then + (workspaceId? : Option WorkspaceId) + (clientRequestId? : Option String) : IO (Except BrokerFailure ActiveRequest) := do + let cancelRef ← IO.mkRef false + registry.mutex.atomically do + let state ← get + unless state.accepting do + return .error { + code := .requestCancelled + message := "Beam session owner is closing" + } + match clientRequestId? with + | none => + let active : ActiveRequest := { + workspaceId?, clientRequestId?, token := state.nextToken, cancelRef + } + set { state with + nextToken := state.nextToken + 1 + anonymousRequests := state.anonymousRequests.insert active.token active + } + pure <| .ok active + | some clientRequestId => + let key : ActiveRequestKey := { workspaceId?, clientRequestId } + if state.requests.contains key then pure <| .error { code := .invalidParams - message := s!"clientRequestId '{clientRequestId}' is already active" + message := s!"clientRequestId '{clientRequestId}' is already active in this workspace" } else - let active : ActiveRequest := { clientRequestId, token := state.nextToken, cancelRef } - set ({ + let active : ActiveRequest := { + workspaceId?, clientRequestId?, token := state.nextToken, cancelRef + } + set { state with nextToken := state.nextToken + 1 - requests := state.requests.insert clientRequestId active - } : ActiveRequestRegistryState) - pure <| .ok <| some active + requests := state.requests.insert key active + } + pure <| .ok active def unregister (registry : ActiveRequestRegistry) @@ -364,20 +433,65 @@ def unregister match active? with | none => pure () | some active => - registry.mutex.atomically do + let shouldResolve ← registry.mutex.atomically do let state ← get - match state.requests.get? active.clientRequestId with - | some current => - if current.token == active.token then - set { state with requests := state.requests.erase active.clientRequestId } - | none => - pure () + let state := + match active.clientRequestId? with + | some clientRequestId => + let key : ActiveRequestKey := { workspaceId? := active.workspaceId?, clientRequestId } + match state.requests.get? key with + | some current => + if current.token == active.token then + { state with requests := state.requests.erase key } + else + state + | none => state + | none => + match state.anonymousRequests.get? active.token with + | some current => + if current.token == active.token then + { state with anonymousRequests := state.anonymousRequests.erase active.token } + else + state + | none => state + let (state, shouldResolve) := markDrainedIfReady state + set state + pure shouldResolve + resolveDrainedIfNeeded registry shouldResolve + +def count (registry : ActiveRequestRegistry) : IO Nat := do + registry.mutex.atomically do + pure (activeRequestCount (← get)) + +/-- Atomically close request admission and mark every admitted request for cancellation. -/ +def closeAdmission (registry : ActiveRequestRegistry) : IO Unit := do + let (active, shouldResolve) ← registry.mutex.atomically do + let state : ActiveRequestRegistryState ← get + if !state.accepting then + pure (#[], false) + else + let (state, shouldResolve) := markDrainedIfReady { state with accepting := false } + set state + let named := state.requests.toList.map Prod.snd |>.toArray + let anonymous := state.anonymousRequests.toList.map Prod.snd |>.toArray + pure (named ++ anonymous, shouldResolve) + for request in active do + request.cancelRef.set true + resolveDrainedIfNeeded registry shouldResolve + +/-- Wait until admission is closed and every request admitted before closure has unregistered. -/ +def awaitDrained (registry : ActiveRequestRegistry) : IO Unit := do + let some _ ← IO.wait registry.drained.result? + | throw <| IO.userError "active request registry drain promise dropped" + pure () def markCancelled (registry : ActiveRequestRegistry) + (workspaceId? : Option WorkspaceId) (clientRequestId : String) : IO (Option ActiveRequest) := do registry.mutex.atomically do - let active? := (← get).requests.get? clientRequestId + let key : ActiveRequestKey := { workspaceId?, clientRequestId } + let active? := (← get).requests.get? key match active? with | none => pure none @@ -389,9 +503,14 @@ def markCancelledActive (registry : ActiveRequestRegistry) (active : ActiveRequest) : IO (Option ActiveRequest) := do registry.mutex.atomically do - match (← get).requests.get? active.clientRequestId with - | none => - pure none + let state ← get + let current? := + match active.clientRequestId? with + | some clientRequestId => + state.requests.get? { workspaceId? := active.workspaceId?, clientRequestId } + | none => state.anonymousRequests.get? active.token + match current? with + | none => pure none | some current => if current.token == active.token then current.cancelRef.set true diff --git a/Beam/Broker/Protocol.lean b/Beam/Broker/Protocol.lean index 8a0cb70a..e83a8974 100644 --- a/Beam/Broker/Protocol.lean +++ b/Beam/Broker/Protocol.lean @@ -14,6 +14,12 @@ namespace Beam.Broker abbrev WorkspaceId := Beam.Workspace.WorkspaceId +/-- Identity of one wrapper-owned daemon generation. -/ +structure DaemonIdentity where + daemonId : String + configHash : String + deriving BEq, Repr, FromJson, ToJson + instance : Repr Lsp.DiagnosticSeverity where reprPrec severity _ := match severity with @@ -207,6 +213,7 @@ structure Request where workspaceId? : Option WorkspaceId := none workspaceMode? : Option Beam.Workspace.InitMode := none clientRequestId? : Option String := none + daemonCapability? : Option String := none cancelRequestId? : Option String := none root? : Option String := none path? : Option String := none @@ -243,15 +250,24 @@ inductive WorkspaceScope where /-- Describe whether a broker operation is process-wide or resolves one workspace. -/ def Op.workspaceScope : Op → WorkspaceScope - | .cancel | .listWorkspaces | .resetStats | .shutdown => .none + | .listWorkspaces | .resetStats | .shutdown => .none | .openDocs | .stats => .optional + | .cancel | .ensure | .updateFile | .syncFile | .refreshFile | .close | .runAt | .hover | .signatureHelp | .definition | .references | .documentSymbols | .workspaceSymbols | .codeActionResolve | .saveOlean | .goals | .todo | .runWith | .release | .initWorkspace | .dropWorkspace => .required +/-- Whether an operation participates in active-request tracking and exact cancellation. -/ +def Op.tracksActiveRequest : Op → Bool + | .cancel | .shutdown => false + | .ensure | .openDocs | .updateFile | .syncFile | .refreshFile | .close | .runAt | .hover + | .signatureHelp | .definition | .references | .documentSymbols | .workspaceSymbols + | .codeActionResolve | .saveOlean | .goals | .todo | .runWith | .release | .initWorkspace + | .listWorkspaces | .dropWorkspace | .stats | .resetStats => true + private def Op.optionalRequestFields (op : Op) : Array String := - #["clientRequestId"] ++ + #["clientRequestId", "daemonCapability"] ++ (match op.workspaceScope with | .none => #[] | .optional | .required => #["workspaceId"]) ++ @@ -306,6 +322,7 @@ private def Request.optionalJsonFields (req : Request) : List (String × Json) : optionalJsonField "workspaceId" req.workspaceId? ++ optionalJsonField "workspaceMode" req.workspaceMode? ++ optionalJsonField "clientRequestId" req.clientRequestId? ++ + optionalJsonField "daemonCapability" req.daemonCapability? ++ optionalJsonField "cancelRequestId" req.cancelRequestId? ++ optionalJsonField "root" req.root? ++ optionalJsonField "path" req.path? ++ @@ -383,6 +400,7 @@ instance : FromJson Request where let workspaceId? ← optionalField? (α := WorkspaceId) j "workspaceId" let workspaceMode? ← optionalField? (α := Beam.Workspace.InitMode) j "workspaceMode" let clientRequestId? ← optionalField? (α := String) j "clientRequestId" + let daemonCapability? ← optionalField? (α := String) j "daemonCapability" let cancelRequestId? ← optionalField? (α := String) j "cancelRequestId" let root? ← optionalField? (α := String) j "root" let path? ← optionalField? (α := String) j "path" @@ -410,7 +428,8 @@ instance : FromJson Request where let handle? ← optionalField? (α := Handle) j "handle" let codeAction? ← optionalField? (α := Lsp.CodeAction) j "codeAction" let request : Request := { - op, backend, workspaceId?, workspaceMode?, clientRequestId?, cancelRequestId?, + op, backend, workspaceId?, workspaceMode?, clientRequestId?, daemonCapability?, + cancelRequestId?, root?, path?, version?, line?, character?, endLine?, endCharacter?, text?, query?, includeDeclaration?, kinds?, suggest?, storeHandle?, linear?, mode?, compact?, ppFormat?, diagnosticScope?, diagnosticsInResult?, @@ -419,6 +438,73 @@ instance : FromJson Request where request.validateFields pure request +/-- +A semantic request accepted by the supported project-session client. + +Session routing and authority are supplied by the selected session descriptor, not by caller JSON. +The generic `Request` remains the internal broker protocol used by maintenance tooling. +-/ +structure ProjectRequest where + private request : Request + private requestId : String + +private def projectRequestForbiddenFields : Array String := + #["workspaceId", "workspaceMode", "daemonCapability", "root", "leanCmd", "leanPlugin", "rocqCmd"] + +private def ProjectRequest.supportedOp : Op → Bool + | .ensure | .openDocs | .cancel | .updateFile | .syncFile | .refreshFile | .close | .runAt + | .hover | .signatureHelp | .definition | .references | .documentSymbols | .workspaceSymbols + | .codeActionResolve | .saveOlean | .goals | .todo | .runWith | .release | .stats => true + | .initWorkspace | .listWorkspaces | .dropWorkspace | .resetStats | .shutdown => false + +def ProjectRequest.ofRequest (request : Request) : Except String ProjectRequest := do + unless ProjectRequest.supportedOp request.op do + throw s!"broker op '{request.op.key}' is not available through a project session" + if request.workspaceId?.isSome || request.workspaceMode?.isSome || + request.daemonCapability?.isSome || request.root?.isSome || request.leanCmd?.isSome || + request.leanPlugin?.isSome || request.rocqCmd?.isSome then + throw "project requests cannot select session routing, authority, or executable configuration" + let some clientRequestId := request.clientRequestId? + | throw "project requests require a non-empty clientRequestId" + if clientRequestId.isEmpty then + throw "project requests require a non-empty clientRequestId" + request.validateFields + pure { request, requestId := clientRequestId } + +instance : FromJson ProjectRequest where + fromJson? json := do + match json with + | .obj fields => + let forbidden := projectRequestForbiddenFields.filter fields.contains + unless forbidden.isEmpty do + throw s!"project request contains session-owned fields: {String.intercalate ", " forbidden.toList}" + | _ => pure () + ProjectRequest.ofRequest (← fromJson? json) + +def ProjectRequest.op (request : ProjectRequest) : Op := + request.request.op + +def ProjectRequest.clientRequestId (request : ProjectRequest) : String := + request.requestId + +/-- Attach one semantic request to a selected, authenticated workspace session. -/ +def ProjectRequest.attach + (request : ProjectRequest) + (workspaceId : WorkspaceId) + (root capability : String) : Request := + let request := request.request + let request := { request with daemonCapability? := some capability } + match request.op.workspaceScope with + | .none => request + | .optional | .required => + let request := { request with workspaceId? := some workspaceId } + if request.op == .cancel then + request + else { + request with + root? := some root + } + structure Error where code : String message : String := "" diff --git a/Beam/Broker/Server.lean b/Beam/Broker/Server.lean index 150276e7..2c24a692 100644 --- a/Beam/Broker/Server.lean +++ b/Beam/Broker/Server.lean @@ -120,13 +120,13 @@ private def requestMethod (method : Except String String) : HandlerM String := | .ok method => pure method | .error msg => throw <| responseFailureFor .invalidParams msg -private def runHandler (act : HandlerM (Response × Bool)) : IO (Response × Bool) := do +private def runHandler (act : HandlerM Response) : IO Response := do try match ← act.run with | .ok result => pure result - | .error failure => pure (failure.toResponse, false) + | .error failure => pure failure.toResponse catch e => - pure (errorResponseFor .internalError e.toString, false) + pure <| errorResponseFor .internalError e.toString private def mkSessionToken : IO String := do let pid ← IO.Process.getPID @@ -182,35 +182,30 @@ private partial def waitForProcessExitWithTimeout loop (remainingMs - min pollMs remainingMs) loop timeoutMs -private def shutdownSession (session : Session) : IO Unit := do - try - writeLspRequest session.stdin ({ id := 0, method := "shutdown", param := Json.null : Lean.JsonRpc.Request Json }) - let task ← IO.asTask (prio := Task.Priority.dedicated) session.stdout.readLspMessage - let _ ← waitForTaskWithTimeout task sessionShutdownReplyTimeoutMs - pure () - catch _ => - pure () - try - writeLspNotification session.stdin ({ method := "exit", param := Json.null : Lean.JsonRpc.Notification Json }) - catch _ => - pure () - unless ← waitForProcessExitWithTimeout session.proc sessionShutdownReplyTimeoutMs do +private def terminateBackendProcess (proc : IO.Process.Child brokerStdio) : IO Unit := do + let running ← try - session.proc.kill + pure (← proc.tryWait).isNone catch _ => - pure () + pure true + if running then try - if let some kill := ← killCommand? then - let _ ← IO.Process.output { - cmd := kill.toString - args := #["-9", toString session.proc.pid.toNat] - } - pure () + proc.kill catch _ => pure () - discard <| waitForProcessExitWithTimeout session.proc sessionShutdownReplyTimeoutMs + unless ← waitForProcessExitWithTimeout proc sessionShutdownReplyTimeoutMs do + try + if let some kill := ← killCommand? then + let _ ← IO.Process.output { + cmd := kill.toString + args := #["-9", toString proc.pid.toNat] + } + pure () + catch _ => + pure () + discard <| waitForProcessExitWithTimeout proc sessionShutdownReplyTimeoutMs try - discard <| session.proc.tryWait + discard <| proc.tryWait catch _ => pure () @@ -404,18 +399,17 @@ private def startWaitDiagnosticsWatchdog s!"waitForDiagnostics watchdog after {timeoutMs}ms: {label}" pure () -private def awaitPending (promise : IO.Promise (Except ResponseFailure PendingResult)) : - HandlerM PendingResult := do - requestArg (← liftHandlerIO <| PendingRequest.awaitOutcome promise) +private def awaitPending (pending : PendingRequest) : HandlerM PendingResult := do + requestArg (← liftHandlerIO pending.awaitOutcome) private def awaitWaitForDiagnosticsBarrier (label : String) - (promise : IO.Promise (Except ResponseFailure PendingResult)) : HandlerM PendingResult := do + (pending : PendingRequest) : HandlerM PendingResult := do let doneRef ← liftHandlerIO <| IO.mkRef false liftHandlerIO <| startWaitDiagnosticsWatchdog label doneRef let outcome ← liftHandlerIO <| do try - let outcome ← PendingRequest.awaitOutcome promise + let outcome ← pending.awaitOutcome doneRef.set true pure outcome catch e => @@ -463,14 +457,7 @@ partial def sessionReaderLoop (session : Session) : IO Unit := do code := .workerExited message := e.toString } - try - session.proc.kill - catch _ => - pure () - try - discard <| session.proc.tryWait - catch _ => - pure () + terminateBackendProcess session.proc private def startRequestJsonTrackedDetailed (session : Session) @@ -483,14 +470,14 @@ private def startRequestJsonTrackedDetailed (diagnosticScope : DiagnosticScope := .errors) (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) (cancelRef? : Option (IO.Ref Bool) := none) : - IO (Session × IO.Promise (Except ResponseFailure PendingResult)) := do + IO (Session × PendingRequest) := do let (session, id) := nextRequestId session let progressRef ← IO.mkRef (initialProgress? <|> tracked.map (fun _ => {})) let diagnosticsRef ← IO.mkRef #[] let diagnosticsSeenRef ← IO.mkRef false let seenDiagnosticKeysRef ← IO.mkRef ({} : Std.TreeSet String compare) let promise ← IO.Promise.new - PendingRequestStore.insert session.pending id { + let pending : PendingRequest := { cancelRef? := cancelRef? promise := promise tracked? := tracked @@ -503,12 +490,13 @@ private def startRequestJsonTrackedDetailed emitDiagnostic? := emitDiagnostic? : PendingRequest } + PendingRequestStore.insert session.pending id pending traceBroker s!"lsp request inserted id={id} method={method} clientRequestId={optionLabel clientRequestId?} tracked={tracked.isSome}" try writeLspRequest session.stdin ({ id, method, param : Lean.JsonRpc.Request Json }) traceBroker s!"lsp request sent id={id} method={method}" - pure (session, promise) + pure (session, pending) catch e => discard <| PendingRequestStore.remove session.pending id traceBroker s!"lsp request send failed id={id} method={method} error={e.toString}" @@ -518,6 +506,29 @@ private def startRequestJsonTrackedDetailed pure () throw e +private def shutdownSession (session : Session) : IO Unit := do + let session ← + try + let (session, pending) ← + startRequestJsonTrackedDetailed session "shutdown" Json.null + let task ← IO.asTask (prio := Task.Priority.dedicated) pending.awaitOutcome + if (← waitForTaskWithTimeout task sessionShutdownReplyTimeoutMs).isNone then + PendingRequestStore.failAll session.pending <| BrokerFailure.toResponseFailure { + code := .workerExited + message := "backend session shutdown timed out" + } + discard <| waitForTaskWithTimeout task sessionShutdownReplyTimeoutMs + pure session + catch _ => + pure session + try + writeLspNotification session.stdin + ({ method := "exit", param := Json.null : Lean.JsonRpc.Notification Json }) + catch _ => + pure () + unless ← waitForProcessExitWithTimeout session.proc sessionShutdownReplyTimeoutMs do + terminateBackendProcess session.proc + def sendRequestJsonTrackedDetailed (session : Session) (method : String) @@ -529,10 +540,10 @@ def sendRequestJsonTrackedDetailed (diagnosticScope : DiagnosticScope := .errors) (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : IO (Except ResponseFailure (Session × Json × Option SyncFileProgress × Array Diagnostic)) := do - let (session, promise) ← + let (session, pending) ← startRequestJsonTrackedDetailed session method param clientRequestId? tracked initialProgress? emitProgress? diagnosticScope emitDiagnostic? - match ← PendingRequest.awaitOutcome promise with + match ← pending.awaitOutcome with | .ok pending => pure <| .ok (session, pending.result, pending.progress?, pending.diagnostics) | .error failure => pure <| .error failure @@ -555,6 +566,76 @@ private partial def awaitInitializeResponse (stdout : IO.FS.Stream) : IO Unit := | .request .. => throw <| IO.userError "unexpected server request before initialize completed" +private def backendInitializeTimeoutMs : Nat := + 30000 + +/-- +Acquire a fully initialized backend session or terminate the provisional child before failing. + +The caller adopts the returned session into broker state. No child ownership escapes this function +until the initialization response and `initialized` notification have both completed. +-/ +private def acquireBackendSession + (workspaceId : WorkspaceId) + (backend : Backend) + (config : BrokerConfig) + (epoch : Nat) : IO Session := do + let root := config.root + let (cmd, args, env) ← backendCommand config backend + let proc ← IO.Process.spawn { + toStdioConfig := brokerStdio + cmd := cmd + args := args + env := env + cwd := root.toString + } + let (session, initializeTask) ← + try + let stdin := IO.FS.Stream.ofHandle proc.stdin + let stdout := IO.FS.Stream.ofHandle proc.stdout + let pending ← PendingRequestStore.create + let sessionToken ← mkSessionToken + let session : Session := { + workspaceId + backend + root + epoch + sessionToken + proc + stdin + stdout + pending + } + writeLspRequest stdin + ({ id := 0, method := "initialize", param := initializeParams backend root + : Lean.JsonRpc.Request Json }) + let initializeTask ← IO.asTask (prio := Task.Priority.dedicated) <| + awaitInitializeResponse stdout + pure (session, initializeTask) + catch err => + terminateBackendProcess proc + throw err + try + match ← waitForTaskWithTimeout initializeTask backendInitializeTimeoutMs with + | some (.ok ()) => pure () + | some (.error err) => throw err + | none => + throw <| IO.userError <| + s!"backend initialize timed out after {backendInitializeTimeoutMs} ms" + writeLspNotification session.stdin + ({ method := "initialized", param := Json.mkObj [] : Lean.JsonRpc.Notification Json }) + let _ ← IO.asTask (prio := Task.Priority.dedicated) do + try + sessionReaderLoop session + catch e => + IO.eprintln s!"broker session reader task failed: {e.toString}" + pure session + catch err => + IO.cancel initializeTask + terminateBackendProcess proc + discard <| waitForTaskWithTimeout initializeTask sessionShutdownReplyTimeoutMs + throw err + private def requireWorkspace (workspaceId : WorkspaceId) : M WorkspaceState := do let state ← get match getWorkspace? state workspaceId with @@ -568,7 +649,6 @@ private def ensureSession (workspaceId : WorkspaceId) (backend : Backend) : M Se | some workspace => pure workspace | none => throw <| IO.userError s!"unknown Beam workspace '{workspaceId}'" let config := workspace.config - let root := config.root let backendState := getBackendState workspace backend let (backendState, restart) ← match backendState.session? with | some session => @@ -587,37 +667,8 @@ private def ensureSession (workspaceId : WorkspaceId) (backend : Backend) : M Se | none => st pure session | none => - let (cmd, args, env) ← backendCommand config backend - let proc ← IO.Process.spawn { - toStdioConfig := brokerStdio - cmd := cmd - args := args - env := env - cwd := root.toString - } - let stdin := IO.FS.Stream.ofHandle proc.stdin - let stdout := IO.FS.Stream.ofHandle proc.stdout - let pending ← PendingRequestStore.create - let sessionToken ← mkSessionToken - let mut session : Session := { - workspaceId - backend - root - epoch := backendState.nextEpoch - sessionToken - proc - stdin - stdout - pending - } - writeLspRequest stdin ({ id := 0, method := "initialize", param := initializeParams backend root : Lean.JsonRpc.Request Json }) - awaitInitializeResponse stdout - writeLspNotification stdin ({ method := "initialized", param := Json.mkObj [] : Lean.JsonRpc.Notification Json }) - let _ ← IO.asTask (prio := Task.Priority.dedicated) do - try - sessionReaderLoop session - catch e => - IO.eprintln s!"broker session reader task failed: {e.toString}" + let session ← + acquireBackendSession workspaceId backend config backendState.nextEpoch recordSessionSpawn workspaceId backend restart let backendState := { backendState with session? := some session } modify fun st => @@ -883,11 +934,33 @@ private def modifyCurrentSessionIfMatching | none => pure () +inductive ServerMode where + /-- A separately managed broker, optionally carrying a public generation identity. -/ + | standalone (identity? : Option DaemonIdentity) + /-- A wrapper-owned broker whose identity and request capability are inseparable. -/ + | wrapper (identity : DaemonIdentity) (capability : String) + +def ServerMode.identity? : ServerMode → Option DaemonIdentity + | .standalone identity? => identity? + | .wrapper identity _ => some identity + +private def ServerMode.validate : ServerMode → Except String Unit + | .standalone none => pure () + | .standalone (some identity) => do + unless !identity.daemonId.isEmpty && !identity.configHash.isEmpty do + throw "daemon identity values must be non-empty" + | .wrapper identity capability => do + unless !identity.daemonId.isEmpty && !identity.configHash.isEmpty do + throw "wrapper-owned daemon identity values must be non-empty" + unless !capability.isEmpty do + throw "wrapper-owned daemon capability must be non-empty" + structure ServerRuntime where state : Std.Mutex State - endpoint : Transport.Endpoint - stop : IO.Ref Bool + private mode : ServerMode activeRequests : ActiveRequestRegistry + private closeMutex : Std.Mutex Bool + private closeDone : IO.Promise (Except IO.Error Unit) /-- A cancellation capability bound to one active broker request admission. @@ -906,19 +979,40 @@ def ServerRuntime.withState (server : ServerRuntime) (act : M α) : IO α := do set state pure a +/-- Return the canonical root currently owned by `workspaceId`, if that workspace exists. -/ +def ServerRuntime.workspaceRoot? + (server : ServerRuntime) + (workspaceId : WorkspaceId) : IO (Option System.FilePath) := do + server.withState do + pure <| (getWorkspace? (← get) workspaceId).map (·.config.root) + +private def ServerRuntime.statsResponse + (server : ServerRuntime) + (workspaceId? : Option WorkspaceId := none) : IO Response := do + let payload ← server.withState <| statsPayload workspaceId? + let payload := + match server.mode.identity? with + | some identity => payload.setObjVal! "daemonIdentity" (toJson identity) + | none => payload + pure <| Response.success payload + def ServerRuntime.create (config : BrokerConfig) (workspaceId : WorkspaceId) - (endpoint : Transport.Endpoint := .tcp 0) : IO ServerRuntime := do + (mode : ServerMode := .standalone none) : IO ServerRuntime := do unless validWorkspaceId workspaceId do throw <| IO.userError "workspace id must be non-empty" + match mode.validate with + | .ok () => pure () + | .error err => throw <| IO.userError err let startMonoNanos ← IO.monoNanosNow let state := mkInitialState config workspaceId startMonoNanos pure { state := ← Std.Mutex.new state - endpoint := endpoint - stop := ← IO.mkRef false + mode activeRequests := ← ActiveRequestRegistry.create + closeMutex := ← Std.Mutex.new false + closeDone := ← IO.Promise.new } private def brokerConfigSame (left right : BrokerConfig) : Bool := @@ -927,12 +1021,97 @@ private def brokerConfigSame (left right : BrokerConfig) : Bool := left.leanPlugin? == right.leanPlugin? && left.rocqCmd? == right.rocqCmd? -private def shutdownWorkspaceSessions (workspace : WorkspaceState) : IO Unit := do - for session? in [workspace.lean.session?, workspace.rocq.session?] do - if let some session := session? then - shutdownSession session +private def detachBackendSession + (backend : BackendState) : BackendState × Option Session := + match backend.session? with + | none => (backend, none) + | some session => + ({ backend with session? := none, nextEpoch := backend.nextEpoch + 1 }, some session) + +private def collectSessions + (left? right? : Option Session) : Array Session := + #[left?, right?].filterMap id + +private def detachWorkspaceSessions + (workspace : WorkspaceState) : WorkspaceState × Array Session := + let (lean, leanSession?) := detachBackendSession workspace.lean + let (rocq, rocqSession?) := detachBackendSession workspace.rocq + ({ workspace with lean, rocq }, collectSessions leanSession? rocqSession?) -def workspaceInitResult +private def detachRuntimeSessions (server : ServerRuntime) : IO (Array Session) := do + server.withState do + let state ← get + let (state, sessions) := state.workspaces.toList.foldl (init := (state, [])) fun + (state, sessions) (workspaceId, workspace) => + let (workspace, detached) := detachWorkspaceSessions workspace + (setWorkspace state workspaceId workspace, detached.toList.reverse ++ sessions) + set state + pure sessions.reverse.toArray + +private def recordFirstCleanupError + (firstError? : Option IO.Error) + (phase : IO Unit) : IO (Option IO.Error) := do + try + phase + pure firstError? + catch err => + pure (firstError? <|> some err) + +private def shutdownSessionsBestEffort : + List Session → Option IO.Error → IO Unit + | [], none => pure () + | [], some err => throw err + | session :: sessions, firstError? => do + let firstError? ← recordFirstCleanupError firstError? <| shutdownSession session + shutdownSessionsBestEffort sessions firstError? + +private def shutdownRuntimeSessions (server : ServerRuntime) : IO Unit := do + shutdownSessionsBestEffort (← detachRuntimeSessions server).toList none + +private def awaitRuntimeClose + (promise : IO.Promise (Except IO.Error Unit)) : IO Unit := do + let some outcome ← IO.wait promise.result? + | throw <| IO.userError "broker runtime close promise dropped" + match outcome with + | .ok () => pure () + | .error err => throw err + +private def ServerRuntime.closeStarted (server : ServerRuntime) : IO Bool := + server.closeMutex.atomically get + +/-- +Close broker admission, cancel admitted requests, shut down every backend session, and wait for +all admitted dispatch scopes to unregister. Concurrent and repeated callers wait for the same +close result. +-/ +def ServerRuntime.close (server : ServerRuntime) : IO Unit := do + let leadsClose ← server.closeMutex.atomically do + if ← get then + pure false + else + set true + pure true + if leadsClose then + -- Retain the first failure but run every teardown phase. In particular, a failed first session + -- sweep must not skip admission drain or the final sweep for sessions created during closure. + let firstError? ← recordFirstCleanupError none <| + ActiveRequestRegistry.closeAdmission server.activeRequests + let firstError? ← recordFirstCleanupError firstError? <| shutdownRuntimeSessions server + let firstError? ← recordFirstCleanupError firstError? <| + ActiveRequestRegistry.awaitDrained server.activeRequests + let firstError? ← recordFirstCleanupError firstError? <| shutdownRuntimeSessions server + let outcome := + match firstError? with + | none => .ok () + | some err => .error err + server.closeDone.resolve outcome + match outcome with + | .ok () => pure () + | .error err => throw err + else + awaitRuntimeClose server.closeDone + +private def workspaceInitResult (workspaceId : WorkspaceId) (root : System.FilePath) (mode : Beam.Workspace.InitMode) @@ -957,46 +1136,105 @@ private def duplicateRootWorkspace? else none -def ServerRuntime.initWorkspaceWithConfig - (server : ServerRuntime) +private structure WorkspaceTransition (α : Type) where + state : State + result : Except ResponseFailure α + detachedSessions : Array Session := #[] + +private def initWorkspaceTransition + (state : State) (workspaceId : WorkspaceId) (config : BrokerConfig) - (mode? : Option Beam.Workspace.InitMode := none) : IO Response := do + (mode? : Option Beam.Workspace.InitMode) : WorkspaceTransition Beam.Workspace.InitResult := if !validWorkspaceId workspaceId then - return errorResponseFor .invalidParams "workspace id must be non-empty" - let mode := mode?.getD .set - server.withState do - let state ← get + { state, result := .error <| responseFailureFor .invalidParams + "workspace id must be non-empty" } + else + let mode := mode?.getD .set match getWorkspace? state workspaceId with | some current => if mode == .reset then if let some otherId := duplicateRootWorkspace? state workspaceId config then - pure <| errorResponseFor .invalidParams <| - s!"workspace root {config.root} is already owned by workspace '{otherId}'" + { state, result := .error <| responseFailureFor .invalidParams <| + s!"workspace root {config.root} is already owned by workspace '{otherId}'" } else - shutdownWorkspaceSessions current + let (_, detachedSessions) := detachWorkspaceSessions current let replacement := mkWorkspaceState config - modify fun state => setWorkspace state workspaceId replacement - pure <| Response.success <| toJson <| - workspaceInitResult workspaceId config.root mode false true (some current.config.root) + { + state := setWorkspace state workspaceId replacement + result := .ok <| + workspaceInitResult workspaceId config.root mode false true + (some current.config.root) + detachedSessions + } else if brokerConfigSame current.config config then - pure <| Response.success <| toJson <| - workspaceInitResult workspaceId current.config.root mode true false + { state, result := .ok <| + workspaceInitResult workspaceId current.config.root mode true false } else - pure <| errorResponseFor .invalidParams <| - s!"workspace '{workspaceId}' is already initialized for {current.config.root}; " ++ - s!"use workspaceMode=reset to switch it explicitly to {config.root}" + { state, result := .error <| responseFailureFor .invalidParams <| + s!"workspace '{workspaceId}' is already initialized for {current.config.root}; " ++ + s!"use workspaceMode=reset to switch it explicitly to {config.root}" } | none => if mode == .verify then - pure <| errorResponseFor .invalidParams - s!"workspace '{workspaceId}' is not initialized; use workspaceMode=set first" + { state, result := .error <| responseFailureFor .invalidParams <| + s!"workspace '{workspaceId}' is not initialized; use workspaceMode=set first" } else if let some otherId := duplicateRootWorkspace? state workspaceId config then - pure <| errorResponseFor .invalidParams <| - s!"workspace root {config.root} is already owned by workspace '{otherId}'" + { state, result := .error <| responseFailureFor .invalidParams <| + s!"workspace root {config.root} is already owned by workspace '{otherId}'" } else - modify fun state => setWorkspace state workspaceId (mkWorkspaceState config) - pure <| Response.success <| toJson <| - workspaceInitResult workspaceId config.root mode false false + { + state := setWorkspace state workspaceId (mkWorkspaceState config) + result := .ok <| workspaceInitResult workspaceId config.root mode false false + } + +private def dropWorkspaceTransition + (state : State) + (workspaceId : WorkspaceId) : WorkspaceTransition Beam.Workspace.DropResult := + if !validWorkspaceId workspaceId then + { state, result := .error <| responseFailureFor .invalidParams + "workspace id must be non-empty" } + else + match getWorkspace? state workspaceId with + | none => + { state, result := .ok { + workspaceId + dropped := false + reason? := some "notFound" + } } + | some workspace => + let (_, detachedSessions) := detachWorkspaceSessions workspace + { + state := { state with workspaces := state.workspaces.erase workspaceId } + result := .ok { + workspaceId + dropped := true + invalidatedHandles := true + } + detachedSessions + } + +private def ServerRuntime.runWorkspaceTransition + (server : ServerRuntime) + (transition : State → WorkspaceTransition α) : IO (Except ResponseFailure α) := do + let transition ← server.withState do + let transition := transition (← get) + set transition.state + pure transition + shutdownSessionsBestEffort transition.detachedSessions.toList none + pure transition.result + +/-- +Initialize, verify, or reset a workspace through a typed in-process boundary. Reset commits the new +workspace ownership atomically, then drains any detached backend sessions outside the state mutex. +-/ +def ServerRuntime.initWorkspaceWithConfig + (server : ServerRuntime) + (workspaceId : WorkspaceId) + (config : BrokerConfig) + (mode? : Option Beam.Workspace.InitMode := none) : + IO (Except ResponseFailure Beam.Workspace.InitResult) := + server.runWorkspaceTransition fun state => + initWorkspaceTransition state workspaceId config mode? private def workspaceListPayload (state : State) : Json := toJson ({ @@ -1008,30 +1246,20 @@ private def workspaceListPayload (state : State) : Json := } : Beam.Workspace.ListEntry) } : Beam.Workspace.ListResult) +private def responseOfTypedResult [ToJson α] : Except ResponseFailure α → Response + | .ok result => Response.success (toJson result) + | .error failure => failure.toResponse + +/-- +Remove a workspace through a typed in-process boundary. The workspace is erased atomically before +its detached backend sessions are drained outside the state mutex. +-/ def ServerRuntime.dropWorkspace (server : ServerRuntime) - (workspaceId : WorkspaceId) : IO Response := do - if !validWorkspaceId workspaceId then - return errorResponseFor .invalidParams "workspace id must be non-empty" - server.withState do - let state ← get - match getWorkspace? state workspaceId with - | none => - pure <| Response.success <| toJson ({ - workspaceId - dropped := false - reason? := some "notFound" - } : Beam.Workspace.DropResult) - | some workspace => - shutdownWorkspaceSessions workspace - modify fun state => { state with workspaces := state.workspaces.erase workspaceId } - pure <| Response.success <| toJson ({ - workspaceId - dropped := true - invalidatedHandles := true - } : Beam.Workspace.DropResult) + (workspaceId : WorkspaceId) : IO (Except ResponseFailure Beam.Workspace.DropResult) := + server.runWorkspaceTransition fun state => dropWorkspaceTransition state workspaceId -private def requestTracksActiveRequest : Op → Bool +private def requestRecordsMetrics : Op → Bool | .cancel | .stats | .resetStats | .shutdown | .openDocs | .listWorkspaces => false | _ => true @@ -1040,7 +1268,7 @@ private def recordDispatchMetrics (req : Request) (resp : Response) (startedAt : Nat) : IO Unit := do - if requestTracksActiveRequest req.op then + if requestRecordsMetrics req.op then let finishedAt ← IO.monoNanosNow let latencyMs := (finishedAt - startedAt) / 1000000 if let some workspaceId := req.resolvedWorkspaceId? then @@ -1054,8 +1282,11 @@ private def cancelRegisteredRequest | some active => let sessions ← server.withState do let state ← get - pure <| state.workspaces.toList.flatMap fun (_, workspace) => - [workspace.lean.session?, workspace.rocq.session?] + pure <| state.workspaces.toList.flatMap fun (workspaceId, workspace) => + if active.workspaceId?.all (fun selected => selected == workspaceId) then + [workspace.lean.session?, workspace.rocq.session?] + else + [] for session? in sessions do if let some session := session? then discard <| PendingRequestStore.cancelMatching session.pending session.stdin active.cancelRef @@ -1064,9 +1295,10 @@ private def cancelRegisteredRequest private def cancelActiveRequest (server : ServerRuntime) + (workspaceId? : Option WorkspaceId) (clientRequestId : String) : IO Bool := cancelRegisteredRequest server <| - ActiveRequestRegistry.markCancelled server.activeRequests clientRequestId + ActiveRequestRegistry.markCancelled server.activeRequests workspaceId? clientRequestId /-- Cancel the exact active admission represented by `handle`. @@ -1086,14 +1318,56 @@ private def propagatePendingCancellation (cancelRef? : Option (IO.Ref Bool)) : IO Unit := do PendingRequestStore.propagateCancellation session.pending session.stdin cancelRef? -private def requestStop (server : ServerRuntime) : IO Unit := do - server.stop.set true +private structure ClientPermits where + available : Std.Mutex Nat + +private def ClientPermits.create (count : Nat) : BaseIO ClientPermits := do + pure { available := ← Std.Mutex.new count } + +private def ClientPermits.tryAcquire (permits : ClientPermits) : BaseIO Bool := do + permits.available.atomically do + let available ← get + if available == 0 then + pure false + else + set (available - 1) + pure true + +private def ClientPermits.release (permits : ClientPermits) : BaseIO Unit := do + permits.available.atomically do + modify (· + 1) + +private structure DaemonTransport where + endpoint : Transport.Endpoint + listener : Transport.Listener + stop : IO.Ref Bool + clientPermits : ClientPermits + +private def maxDaemonClients : Nat := + 64 + +private def DaemonTransport.create (endpoint : Transport.Endpoint) : IO DaemonTransport := do + let stop ← IO.mkRef false + let listener ← Transport.bindAndListen endpoint 16 + pure { endpoint, listener, stop, clientPermits := ← ClientPermits.create maxDaemonClients } + +private def requestStop (transport : DaemonTransport) : IO Unit := do + transport.stop.set true try - let conn ← Transport.connect server.endpoint - Transport.closeConnection conn + -- Wake the blocking accept. Both ends are intentionally left to scope cleanup: performing a + -- graceful TCP shutdown on the wake-up pair can wait for its peer and deadlock daemon exit. + discard <| Transport.connect transport.endpoint catch _ => pure () +private def closeAndRequestStop + (server : ServerRuntime) + (transport : DaemonTransport) : IO Unit := do + try + server.close + finally + requestStop transport + private structure WorkspaceRequest extends Request where workspaceId : WorkspaceId @@ -1171,7 +1445,7 @@ private structure StartedSyncedRequest where version : Nat priorProgress? : Option SyncFileProgress := none tracked : Option (DocumentUri × Nat) := none - promise : IO.Promise (Except ResponseFailure PendingResult) + pending : PendingRequest private def trackedDocumentVersion (uri : DocumentUri) (docState : DocState) : Option (DocumentUri × Nat) := @@ -1221,7 +1495,7 @@ private def startSyncedDocumentRequest pure () let tracked := trackedFor uri docState let params := mkParams uri docState - let (session, promise) ← + let (session, pending) ← startRequestJsonTrackedDetailed session method params (clientRequestId? := clientRequestId?) (tracked := tracked) @@ -1237,7 +1511,7 @@ private def startSyncedDocumentRequest version := docState.version priorProgress? := docState.fileProgress? tracked - promise + pending } private def awaitSyncedDocumentRequest @@ -1245,7 +1519,7 @@ private def awaitSyncedDocumentRequest (started : StartedSyncedRequest) (cancelRef? : Option (IO.Ref Bool) := none) : HandlerM PendingResult := do liftHandlerIO <| propagatePendingCancellation started.session cancelRef? - let pending ← awaitPending started.promise + let pending ← awaitPending started.pending if started.tracked.isSome then withFailureProgress pending.progress? <| liftHandlerIO <| mergeFileProgressIfCurrent server started.session started.uri pending.progress? @@ -1274,7 +1548,7 @@ private structure StartedTrackedBarrier where textMTime : Lake.MTime changed : Bool := false priorProgress? : Option SyncFileProgress := none - promise : IO.Promise (Except ResponseFailure PendingResult) + pending : PendingRequest private def startTrackedDiagnosticsBarrierIO (server : ServerRuntime) @@ -1294,7 +1568,7 @@ private def startTrackedDiagnosticsBarrierIO let tracked := trackedDocumentVersion uri docState let params := toJson (WaitForDiagnosticsParams.mk uri docState.version) let method ← IO.ofExcept <| diagnosticsBarrierMethod session.backend - let (session, promise) ← + let (session, pending) ← startRequestJsonTrackedDetailed session method params (clientRequestId? := req.clientRequestId?) (tracked := tracked) @@ -1313,7 +1587,7 @@ private def startTrackedDiagnosticsBarrierIO textMTime := docState.textMTime changed := synced.changed priorProgress? := docState.fileProgress? - promise + pending } private def finalizeSavedDoc @@ -1464,7 +1738,7 @@ private def saveOleanCore liftHandlerIO <| propagatePendingCancellation started.session cancelRef? let barrier ← awaitWaitForDiagnosticsBarrier s!"save_olean sync barrier clientRequestId={optionLabel req.clientRequestId?} uri={started.uri} version={started.version}" - started.promise + started.pending let barrierResult : DiagnosticsBarrierResult ← withFailureProgress barrier.progress? <| liftHandlerIO <| decodeResponseAs barrier.result if barrierResult.version != started.version then @@ -1532,18 +1806,18 @@ private def saveOleanCore -- Once artifact publication can begin, an older trace must not remain visible: it may have the -- same dependency hash while describing a different in-server artifact family. withFailureProgress barrierProgress? <| liftHandlerIO <| invalidateLeanSaveTrace spec - let (session, savePromise) ← withFailureProgress barrierProgress? <| + let (session, saveRequest) ← withFailureProgress barrierProgress? <| withCurrentMatchingSession server started.session fun current => do - let (current, savePromise) ← startRequestJsonTrackedDetailed current method params + let (current, saveRequest) ← startRequestJsonTrackedDetailed current method params (clientRequestId? := req.clientRequestId?) (cancelRef? := cancelRef?) updateSession current - pure (current, savePromise) + pure (current, saveRequest) withFailureProgress barrierProgress? <| liftHandlerIO <| propagatePendingCancellation session cancelRef? let savePending ← match ← withFailureProgress barrierProgress? <| - liftHandlerIO <| PendingRequest.awaitOutcome savePromise with + liftHandlerIO saveRequest.awaitOutcome with | .ok pending => pure pending | .error failure => throw <| ({ @@ -1596,7 +1870,7 @@ private def handleSyncFileOp (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do if req.backend != .lean then throw <| responseFailureFor .invalidParams "sync_file diagnostics barrier is only supported for Lean" @@ -1609,7 +1883,7 @@ private def handleSyncFileOp liftHandlerIO <| propagatePendingCancellation started.session cancelRef? let pending ← awaitWaitForDiagnosticsBarrier s!"sync_file clientRequestId={optionLabel req.clientRequestId?} uri={started.uri} version={started.version}" - started.promise + started.pending liftHandlerIO <| traceBroker s!"sync_file barrier completed clientRequestId={optionLabel req.clientRequestId?} progress={pending.progress?.isSome} diagnostics={pending.diagnostics.size} diagnosticsSeen={pending.diagnosticsSeen}" let barrierResult : DiagnosticsBarrierResult ← @@ -1645,9 +1919,7 @@ private def handleSyncFileOp recordCompletedSync server started.session started.uri started.version liftHandlerIO <| traceBroker s!"sync_file response ready clientRequestId={optionLabel req.clientRequestId?} version={started.version} saveReady={saveReadiness.saveReady}" - pure (syncFileSuccessResponse - syncResult fileProgress?, - false) + pure <| syncFileSuccessResponse syncResult fileProgress? private def closeTrackedFileIfOpen (server : ServerRuntime) @@ -1667,7 +1939,7 @@ private def handleRefreshFileOp (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let path ← requestArg req.pathArg liftFailureIO <| ensureRequestNotCancelled cancelRef? closeTrackedFileIfOpen server req path @@ -1677,7 +1949,7 @@ private def handleUpdateFileOp (server : ServerRuntime) (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let path ← requestArg req.pathArg liftFailureIO <| ensureRequestNotCancelled cancelRef? let snapshot ← liftHandlerIO <| readRequestSyncSnapshot server req path @@ -1686,11 +1958,11 @@ private def handleUpdateFileOp let synced ← syncFileSnapshotDetailed session snapshot updateSession synced.session pure synced - pure (Response.success (toJson ({ + pure <| Response.success (toJson ({ version := updated.version changed := updated.changed : UpdateFileResult - })), false) + })) private def handleCloseOp (server : ServerRuntime) @@ -1698,21 +1970,21 @@ private def handleCloseOp (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let path ← requestArg req.pathArg if req.saveArtifacts?.getD false then let saved ← saveOlean server req path cancelRef? emitProgress? emitDiagnostic? finalizeSavedDoc server saved.session saved.uri saved.version true - pure (saveCompletedResponse saved true, false) + pure <| saveCompletedResponse saved true else liftHandlerIO <| server.withState do match ← currentSession? req.workspaceId req.backend with | some session => let session ← closeFile session path updateSession session - pure (Response.success (Json.mkObj [("closed", toJson true)]), false) + pure <| Response.success (Json.mkObj [("closed", toJson true)]) | none => - pure (Response.success (Json.mkObj [("closed", toJson true)]), false) + pure <| Response.success (Json.mkObj [("closed", toJson true)]) private def runAtSetupProgressEmitter? (emitDiagnostic? : Option (StreamDiagnostic → IO Unit)) : @@ -1727,7 +1999,7 @@ private def handleRunAtOp (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.runAtArgs liftFailureIO <| ensureRequestNotCancelled cancelRef? let snapshot ← liftHandlerIO <| readRequestSyncSnapshot server req args.path @@ -1749,11 +2021,10 @@ private def handleRunAtOp (emitDiagnostic? := runAtSetupProgressEmitter? emitDiagnostic?) (cancelRef? := cancelRef?) let pending ← awaitSyncedDocumentRequest server started cancelRef? - pure ( + pure <| Response.withOptionalFileProgress (Response.success (wrapResultHandle started.session pending.result)) - pending.progress?, - false) + pending.progress? private def positionLspParams (args : PositionArgs) @@ -1773,7 +2044,7 @@ private def handlePositionLspOp (extraFields : List (String × Json) := []) (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do liftFailureIO <| ensureRequestNotCancelled cancelRef? let snapshot ← liftHandlerIO <| readRequestSyncSnapshot server req args.path let started ← liftFailureIO <| server.withState do @@ -1786,14 +2057,14 @@ private def handlePositionLspOp (emitProgress? := emitProgress?) (cancelRef? := cancelRef?) let pending ← awaitSyncedDocumentRequest server started cancelRef? - pure (Response.withOptionalFileProgress (Response.success pending.result) pending.progress?, false) + pure <| Response.withOptionalFileProgress (Response.success pending.result) pending.progress? private def handleHoverOp (server : ServerRuntime) (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.hoverArgs handlePositionLspOp server req args.toPositionArgs args.method (cancelRef? := cancelRef?) (emitProgress? := emitProgress?) @@ -1803,7 +2074,7 @@ private def handleSignatureHelpOp (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.signatureHelpArgs handlePositionLspOp server req args.toPositionArgs args.method (cancelRef? := cancelRef?) (emitProgress? := emitProgress?) @@ -1813,7 +2084,7 @@ private def handleDefinitionOp (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.definitionArgs handlePositionLspOp server req args.toPositionArgs args.method (cancelRef? := cancelRef?) (emitProgress? := emitProgress?) @@ -1823,7 +2094,7 @@ private def handleReferencesOp (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.referencesArgs handlePositionLspOp server req args.toPositionArgs args.method [("context", Json.mkObj [("includeDeclaration", toJson args.includeDeclaration)])] @@ -1834,7 +2105,7 @@ private def handleDocumentSymbolsOp (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.documentSymbolsArgs liftFailureIO <| ensureRequestNotCancelled cancelRef? let snapshot ← liftHandlerIO <| readRequestSyncSnapshot server req args.path @@ -1850,26 +2121,26 @@ private def handleDocumentSymbolsOp (emitProgress? := emitProgress?) (cancelRef? := cancelRef?) let pending ← awaitSyncedDocumentRequest server started cancelRef? - pure (Response.withOptionalFileProgress (Response.success pending.result) pending.progress?, false) + pure <| Response.withOptionalFileProgress (Response.success pending.result) pending.progress? private def handleWorkspaceSymbolsOp (server : ServerRuntime) (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.workspaceSymbolsArgs liftFailureIO <| ensureRequestNotCancelled cancelRef? - let (session, promise) ← liftHandlerIO <| server.withState do + let (session, request) ← liftHandlerIO <| server.withState do let session ← ensureSession req.workspaceId req.backend let params := toJson ({ query := args.query : WorkspaceSymbolParams }) - let (session, promise) ← startRequestJsonTrackedDetailed session args.method params + let (session, request) ← startRequestJsonTrackedDetailed session args.method params (clientRequestId? := req.clientRequestId?) (cancelRef? := cancelRef?) updateSession session - pure (session, promise) + pure (session, request) liftHandlerIO <| propagatePendingCancellation session cancelRef? - let pending ← awaitPending promise - pure (Response.success pending.result, false) + let pending ← awaitPending request + pure <| Response.success pending.result private def codeActionResolveSourceUri (action : CodeAction) : Except ResponseFailure DocumentUri := do @@ -1888,7 +2159,7 @@ private def handleCodeActionResolveOp (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.codeActionResolveArgs liftFailureIO <| ensureRequestNotCancelled cancelRef? let snapshot ← liftHandlerIO <| readRequestSyncSnapshot server req args.path @@ -1911,7 +2182,7 @@ private def handleCodeActionResolveOp version := started.version codeAction := resolved } - pure (Response.withOptionalFileProgress (Response.success (toJson payload)) pending.progress?, false) + pure <| Response.withOptionalFileProgress (Response.success (toJson payload)) pending.progress? private def handleSaveOleanOp (server : ServerRuntime) @@ -1919,18 +2190,18 @@ private def handleSaveOleanOp (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let path ← requestArg req.pathArg let saved ← saveOlean server req path cancelRef? emitProgress? emitDiagnostic? finalizeSavedDoc server saved.session saved.uri saved.version false - pure (saveCompletedResponse saved false, false) + pure <| saveCompletedResponse saved false private def handleGoalsOp (server : ServerRuntime) (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.goalsArgs if req.backend == .lean && req.text?.isSome then throw <| responseFailureFor .invalidParams @@ -1967,14 +2238,14 @@ private def handleGoalsOp (emitProgress? := emitProgress?) (cancelRef? := cancelRef?) let pending ← awaitSyncedDocumentRequest server started cancelRef? - pure (Response.withOptionalFileProgress (Response.success pending.result) pending.progress?, false) + pure <| Response.withOptionalFileProgress (Response.success pending.result) pending.progress? private def handleTodoOp (server : ServerRuntime) (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.todoArgs liftFailureIO <| ensureRequestNotCancelled cancelRef? let range : Lsp.Range := { @@ -2001,14 +2272,14 @@ private def handleTodoOp (emitProgress? := emitProgress?) (cancelRef? := cancelRef?) let pending ← awaitSyncedDocumentRequest server started cancelRef? - pure (Response.withOptionalFileProgress (Response.success pending.result) pending.progress?, false) + pure <| Response.withOptionalFileProgress (Response.success pending.result) pending.progress? private def handleRunWithOp (server : ServerRuntime) (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.runWithArgs liftFailureIO <| ensureRequestNotCancelled cancelRef? let snapshot ← liftHandlerIO <| readRequestSyncSnapshot server req args.path @@ -2041,18 +2312,17 @@ private def handleRunWithOp (cancelRef? := cancelRef?) pure startedResult let pending ← awaitSyncedDocumentRequest server started cancelRef? - pure ( + pure <| Response.withOptionalFileProgress (Response.success (wrapResultHandle started.session pending.result)) - pending.progress?, - false) + pending.progress? private def handleReleaseOp (server : ServerRuntime) (req : WorkspaceRequest) (cancelRef? : Option (IO.Ref Bool) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) : - HandlerM (Response × Bool) := do + HandlerM Response := do let args ← requestArg req.releaseArgs liftFailureIO <| ensureRequestNotCancelled cancelRef? let snapshot ← liftHandlerIO <| readRequestSyncSnapshot server req args.path @@ -2079,7 +2349,7 @@ private def handleReleaseOp (cancelRef? := cancelRef?) pure startedResult let pending ← awaitSyncedDocumentRequest server started cancelRef? - pure (Response.withOptionalFileProgress (Response.success pending.result) pending.progress?, false) + pure <| Response.withOptionalFileProgress (Response.success pending.result) pending.progress? private def initWorkspaceConfigFromRequest (server : ServerRuntime) @@ -2114,70 +2384,66 @@ private def initWorkspaceConfigFromRequest private def handleRequestIO (server : ServerRuntime) (req : Request) - (cancelRef? : Option (IO.Ref Bool) := none) + (activeRequest? : Option ActiveRequest := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) - (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : IO (Response × Bool) := do + (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : IO Response := do + let cancelRef? := activeRequest?.map (·.cancelRef) match req.op with | .shutdown => - let resp ← server.withState do - let state ← get - for (_, workspace) in state.workspaces.toList do - shutdownWorkspaceSessions workspace - pure <| Response.success (Json.mkObj [("shutdown", toJson true)]) - pure (resp, true) + server.close + pure <| Response.success (Json.mkObj [("shutdown", toJson true)]) | .stats => match req.workspaceId? with - | none => pure (Response.success (← server.withState statsPayload), false) + | none => server.statsResponse | some _ => match ← validateRequestWorkspace server req with - | .error failure => pure (failure.toResponse, false) + | .error failure => pure failure.toResponse | .ok workspaceReq => - pure (Response.success - (← server.withState <| statsPayload (some workspaceReq.workspaceId)), false) + server.statsResponse (some workspaceReq.workspaceId) | .listWorkspaces => let payload ← server.withState do pure <| workspaceListPayload (← get) - pure (Response.success payload, false) + pure <| Response.success payload | .resetStats => let now ← IO.monoNanosNow let resp ← server.withState do resetMetrics now pure <| Response.success (Json.mkObj [("reset", toJson true)]) - pure (resp, false) + pure resp | .openDocs => match req.workspaceId? with - | none => pure (Response.success (← server.withState openDocsPayload), false) + | none => pure <| Response.success (← server.withState openDocsPayload) | some _ => match ← validateRequestWorkspace server req with - | .error failure => pure (failure.toResponse, false) + | .error failure => pure failure.toResponse | .ok workspaceReq => - pure (Response.success - (← server.withState <| openDocsPayload (some workspaceReq.workspaceId)), false) + pure <| Response.success + (← server.withState <| openDocsPayload (some workspaceReq.workspaceId)) | .initWorkspace => match req.requireWorkspaceId with - | .error err => pure (errorResponseFor .invalidParams err, false) + | .error err => pure <| errorResponseFor .invalidParams err | .ok workspaceId => match ← initWorkspaceConfigFromRequest server req with - | .error failure => pure (failure.toResponse, false) + | .error failure => pure failure.toResponse | .ok config => - let resp ← server.initWorkspaceWithConfig workspaceId config req.workspaceMode? - pure (resp, false) + let result ← server.initWorkspaceWithConfig workspaceId config req.workspaceMode? + pure <| responseOfTypedResult result | .dropWorkspace => match req.requireWorkspaceId with - | .error err => pure (errorResponseFor .invalidParams err, false) + | .error err => pure <| errorResponseFor .invalidParams err | .ok workspaceId => - let resp ← server.dropWorkspace workspaceId - pure (resp, false) + let result ← server.dropWorkspace workspaceId + pure <| responseOfTypedResult result | .cancel => let targetClientRequestId ← match req.cancelRequestIdArg with | .ok targetClientRequestId => pure targetClientRequestId - | .error failure => return (failure.toResponse, false) - let cancelled ← cancelActiveRequest server targetClientRequestId - pure (Response.success (Json.mkObj [("cancelled", toJson cancelled)]), false) + | .error failure => return failure.toResponse + let cancelled ← cancelActiveRequest server req.resolvedWorkspaceId? targetClientRequestId + pure <| Response.success (Json.mkObj [("cancelled", toJson cancelled)]) | op => match ← validateRequestWorkspace server req with - | .error failure => pure (failure.toResponse, false) + | .error failure => pure failure.toResponse | .ok workspaceReq => match op with | .ensure => @@ -2194,7 +2460,7 @@ private def handleRequestIO pure <| Response.success payload catch e => pure <| errorResponseFor .internalError e.toString - pure (resp, false) + pure resp | .updateFile => runHandler <| handleUpdateFileOp server workspaceReq cancelRef? | .syncFile => runHandler <| handleSyncFileOp server workspaceReq cancelRef? emitProgress? emitDiagnostic? @@ -2230,36 +2496,49 @@ private def handleRequestIO private def ServerRuntime.withRequestAdmission (server : ServerRuntime) (req : Request) - (act : RequestHandle → IO (Response × Bool)) : IO (Response × Bool) := do + (act : RequestHandle → IO Response) : IO Response := do let startedAt ← IO.monoNanosNow traceBroker s!"dispatch start op={req.op.key} clientRequestId={optionLabel req.clientRequestId?}" + match server.mode with + | .standalone _ => pure () + | .wrapper _ expected => + unless req.daemonCapability? == some expected do + let resp := errorResponseFor .invalidParams "invalid Beam daemon capability" + recordDispatchMetrics server req resp startedAt + return resp + if req.op == .initWorkspace || req.op == .listWorkspaces || req.op == .dropWorkspace then + let resp := errorResponseFor .invalidParams + s!"broker op '{req.op.key}' is unavailable in wrapper-owned daemon mode" + recordDispatchMetrics server req resp startedAt + return resp match req.validateFields with | .error err => let resp := errorResponseFor .invalidParams err traceBroker s!"dispatch rejected op={req.op.key} clientRequestId={optionLabel req.clientRequestId?} error={err}" recordDispatchMetrics server req resp startedAt - return (resp, false) + return resp | .ok () => pure () try let active? ← - if requestTracksActiveRequest req.op then - match ← ActiveRequestRegistry.register server.activeRequests req.clientRequestId? with - | .ok active? => pure active? + if req.op.tracksActiveRequest then + match ← ActiveRequestRegistry.register + server.activeRequests req.resolvedWorkspaceId? req.clientRequestId? with + | .ok active => pure (some active) | .error failure => let resp := BrokerFailure.toResponse failure recordDispatchMetrics server req resp startedAt - return (resp, false) + return resp else pure none try let handle : RequestHandle := { runtime := server, active? } - let (resp, shouldStop) ← act handle + let resp ← act handle traceBroker s!"dispatch complete op={req.op.key} clientRequestId={optionLabel req.clientRequestId?} ok={resp.ok}" recordDispatchMetrics server req resp startedAt - pure (resp, shouldStop) + pure resp finally ActiveRequestRegistry.unregister server.activeRequests active? catch e => @@ -2267,7 +2546,7 @@ private def ServerRuntime.withRequestAdmission traceBroker s!"dispatch exception op={req.op.key} clientRequestId={optionLabel req.clientRequestId?} error={e.toString}" recordDispatchMetrics server req resp startedAt - pure (resp, false) + pure resp /-- Admit `req`, expose its exact cancellation handle to `beforeDispatch`, and @@ -2283,23 +2562,20 @@ def ServerRuntime.dispatchRequestWithHandle (req : Request) (beforeDispatch : RequestHandle → IO Bool) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) - (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : IO (Response × Bool) := do + (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : IO Response := do server.withRequestAdmission req fun handle => do unless ← beforeDispatch handle do - return ( - BrokerFailure.toResponse { - code := .requestCancelled - message := "request was cancelled before broker dispatch" - }, - false - ) - handleRequestIO server req (handle.active?.map (·.cancelRef)) emitProgress? emitDiagnostic? + return BrokerFailure.toResponse { + code := .requestCancelled + message := "request was cancelled before broker dispatch" + } + handleRequestIO server req handle.active? emitProgress? emitDiagnostic? def ServerRuntime.dispatchRequest (server : ServerRuntime) (req : Request) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) - (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : IO (Response × Bool) := do + (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : IO Response := do server.dispatchRequestWithHandle req (fun _ => pure true) emitProgress? emitDiagnostic? private def rootWatchPollMs : UInt32 := @@ -2310,8 +2586,11 @@ A standalone daemon cannot rely on its registry after the project directory disa default registry lives below that directory and is removed with it. Stop the broker proactively so removing a git worktree does not strand either the daemon or its backend processes. -/ -private partial def watchRoot (server : ServerRuntime) (root : System.FilePath) : IO Unit := do - if ← server.stop.get then +private partial def watchRoot + (server : ServerRuntime) + (transport : DaemonTransport) + (root : System.FilePath) : IO Unit := do + if ← transport.stop.get then pure () else let rootAvailable ← @@ -2321,13 +2600,36 @@ private partial def watchRoot (server : ServerRuntime) (root : System.FilePath) pure false if !rootAvailable then IO.eprintln s!"Beam daemon root is no longer available; shutting down: {root}" - discard <| server.dispatchRequest { op := .shutdown } - requestStop server + closeAndRequestStop server transport else IO.sleep rootWatchPollMs - watchRoot server root + watchRoot server transport root -private def handleClient (server : ServerRuntime) (client : Transport.Connection) : IO Unit := do +private def watchSessionOwnerStdin + (server : ServerRuntime) + (transport : DaemonTransport) : IO Unit := do + try + discard <| (← IO.getStdin).readToEnd + catch _ => + pure () + unless ← transport.stop.get do + closeAndRequestStop server transport + +private def watchClientDisconnect + (client : Transport.Connection) + (handle : RequestHandle) : IO Unit := do + try + -- The daemon transport accepts one request per connection. A second receive therefore blocks + -- until the client closes or dies; either outcome should cancel an unfinished admission. + discard <| Transport.recvMsg client + catch _ => + pure () + discard <| handle.cancel + +private def handleClient + (server : ServerRuntime) + (transport : DaemonTransport) + (client : Transport.Connection) : IO Unit := do let clientRequestIdRef ← IO.mkRef (none : Option String) let terminalSentRef ← IO.mkRef false let sendResponse (clientRequestId? : Option String) (resp : Response) : IO Unit := do @@ -2335,7 +2637,10 @@ private def handleClient (server : ServerRuntime) (client : Transport.Connection (toJson (StreamMessage.response clientRequestId? resp)).compress terminalSentRef.set true try - let msg ← Transport.recvMsg client + let initialRequestTimeoutMs := 5000 + let deadlineNanos := (← IO.monoNanosNow) + initialRequestTimeoutMs * 1000000 + let some msg ← Transport.recvMsgUntil client deadlineNanos + | throw <| IO.userError s!"Beam daemon initial request timed out after {initialRequestTimeoutMs} ms" let request : Except ResponseFailure Request ← match Json.parse msg with | .error err => @@ -2360,10 +2665,23 @@ private def handleClient (server : ServerRuntime) (client : Transport.Connection let emitDiagnostic : StreamDiagnostic → IO Unit := fun diagnostic => Transport.sendMsg client (toJson (StreamMessage.diagnostic req.clientRequestId? diagnostic)).compress - let (resp, shouldStop) ← server.dispatchRequest req (some emitProgress) (some emitDiagnostic) - sendResponse req.clientRequestId? resp - if shouldStop then - requestStop server + let resp ← server.dispatchRequestWithHandle req (fun handle => do + let _ ← IO.asTask (prio := Task.Priority.dedicated) <| watchClientDisconnect client handle + pure true) (some emitProgress) (some emitDiagnostic) + -- Request validation alone does not grant shutdown authority. Only stop the listener once + -- dispatch has authenticated the capability and started runtime closure. + let stopsTransport ← + if req.op == .shutdown then server.closeStarted else pure false + if stopsTransport then + -- A successful send is the transport's flush boundary. Wake the listener only after the + -- terminal response has been handed off, but do so even when the caller disconnected so + -- a closed runtime cannot remain behind a live listener. + try + sendResponse req.clientRequestId? resp + finally + requestStop transport + else + sendResponse req.clientRequestId? resp catch e => unless ← terminalSentRef.get do let clientRequestId? ← clientRequestIdRef.get @@ -2375,25 +2693,38 @@ private def handleClient (server : ServerRuntime) (client : Transport.Connection finally Transport.closeConnection client -private partial def acceptLoop (server : ServerRuntime) (listener : Transport.Listener) : IO Unit := do - if ← server.stop.get then +private partial def acceptLoop + (server : ServerRuntime) + (transport : DaemonTransport) : IO Unit := do + if ← transport.stop.get then pure () else - let client ← Transport.accept listener - if ← server.stop.get then + let client ← Transport.accept transport.listener + if ← transport.stop.get then Transport.closeConnection client else - let _ ← IO.asTask (prio := Task.Priority.dedicated) do - try - handleClient server client - catch e => - IO.eprintln s!"broker client task failed: {e.toString}" - acceptLoop server listener + if ← transport.clientPermits.tryAcquire then + let serve := do + try + handleClient server transport client + catch e => + IO.eprintln s!"broker client task failed: {e.toString}" + let _ ← IO.asTask (prio := Task.Priority.dedicated) do + try + serve + finally + transport.clientPermits.release + else + Transport.closeConnection client + acceptLoop server transport private structure CliOptions where endpoint : Transport.Endpoint := .tcp 8765 root? : Option String := none workspaceId? : Option WorkspaceId := none + daemonId? : Option String := none + configHash? : Option String := none + sessionOwnerStdin : Bool := false leanCmd? : Option String := none leanPlugin? : Option String := none rocqCmd? : Option String := none @@ -2419,6 +2750,12 @@ private partial def parseCliOptions (opts : CliOptions) : List String → Except parseCliOptions { opts with root? := some root } rest | "--workspace-id" :: workspaceId :: rest => parseCliOptions { opts with workspaceId? := some workspaceId } rest + | "--daemon-id" :: daemonId :: rest => + parseCliOptions { opts with daemonId? := some daemonId } rest + | "--config-hash" :: configHash :: rest => + parseCliOptions { opts with configHash? := some configHash } rest + | "--session-owner-stdin" :: rest => + parseCliOptions { opts with sessionOwnerStdin := true } rest | "--lean-cmd" :: leanCmd :: rest => parseCliOptions { opts with leanCmd? := some leanCmd } rest | "--lean-plugin" :: leanPlugin :: rest => @@ -2428,6 +2765,96 @@ private partial def parseCliOptions (opts : CliOptions) : List String → Except | arg :: _ => throw s!"unexpected Beam daemon argument '{arg}'" +private abbrev DaemonWatcherTask := Task (Except IO.Error Unit) + +private structure DaemonResources where + runtime : ServerRuntime + transport : DaemonTransport + rootWatcher : DaemonWatcherTask + ownerWatcher? : Option DaemonWatcherTask + +private def closeDaemonParts + (runtime : ServerRuntime) + (transport : DaemonTransport) + (rootWatcher? ownerWatcher? : Option DaemonWatcherTask) : IO Unit := do + let firstError? ← recordFirstCleanupError none <| transport.stop.set true + let firstError? ← recordFirstCleanupError firstError? <| + Transport.closeListener transport.listener + let firstError? ← + match ownerWatcher? with + | none => pure firstError? + | some ownerWatcher => + recordFirstCleanupError firstError? do + try + IO.cancel ownerWatcher + finally + discard <| IO.wait ownerWatcher + let firstError? ← + match rootWatcher? with + | none => pure firstError? + | some rootWatcher => + recordFirstCleanupError firstError? do + discard <| IO.wait rootWatcher + let firstError? ← recordFirstCleanupError firstError? runtime.close + if let some err := firstError? then + throw err + +private def DaemonResources.close (resources : DaemonResources) : IO Unit := + closeDaemonParts resources.runtime resources.transport (some resources.rootWatcher) + resources.ownerWatcher? + +private def throwAfterBestEffortCleanup + (err : IO.Error) + (cleanup : IO Unit) : IO α := do + try + cleanup + catch _ => + pure () + throw err + +private def acquireDaemonResources + (opts : CliOptions) + (config : BrokerConfig) + (workspaceId : WorkspaceId) + (mode : ServerMode) + (root : System.FilePath) : IO DaemonResources := do + let runtime ← ServerRuntime.create config workspaceId mode + let transport ← + try + DaemonTransport.create opts.endpoint + catch err => + throwAfterBestEffortCleanup err runtime.close + let rootWatcher ← + try + IO.asTask (prio := Task.Priority.dedicated) <| watchRoot runtime transport root + catch err => + throwAfterBestEffortCleanup err <| closeDaemonParts runtime transport none none + let ownerWatcher? ← + try + match mode with + | .wrapper _ _ => + some <$> IO.asTask (prio := Task.Priority.dedicated) + (watchSessionOwnerStdin runtime transport) + | .standalone _ => pure none + catch err => + throwAfterBestEffortCleanup err <| + closeDaemonParts runtime transport (some rootWatcher) none + pure { runtime, transport, rootWatcher, ownerWatcher? } + +/-- Acquire the daemon runtime, listener, and watcher tasks for exactly the dynamic extent of `act`. -/ +private def withDaemonResources + (opts : CliOptions) + (config : BrokerConfig) + (workspaceId : WorkspaceId) + (mode : ServerMode) + (root : System.FilePath) + (act : DaemonResources → IO α) : IO α := do + let resources ← acquireDaemonResources opts config workspaceId mode root + try + act resources + finally + resources.close + def main (args : List String) : IO Unit := do let opts ← IO.ofExcept <| parseCliOptions {} args let some root := opts.root? @@ -2436,6 +2863,28 @@ def main (args : List String) : IO Unit := do | throw <| IO.userError "missing Beam daemon --workspace-id ID" unless validWorkspaceId workspaceId do throw <| IO.userError "workspace id must be non-empty" + let daemonIdentity? ← + match opts.daemonId?, opts.configHash? with + | none, none => pure none + | some daemonId, some configHash => + if daemonId.isEmpty || configHash.isEmpty then + throw <| IO.userError "daemon identity values must be non-empty" + pure <| some { daemonId, configHash } + | some _, none => + throw <| IO.userError "--daemon-id requires --config-hash" + | none, some _ => + throw <| IO.userError "--config-hash requires --daemon-id" + let mode : ServerMode ← + if opts.sessionOwnerStdin then + let some identity := daemonIdentity? + | throw <| IO.userError + "wrapper-owned Beam daemon identity and stdin capability must be supplied together" + let capability := (← (← IO.getStdin).getLine).trimAscii.toString + if capability.isEmpty then + throw <| IO.userError "wrapper-owned Beam daemon received an empty capability" + pure <| .wrapper identity capability + else + pure <| .standalone daemonIdentity? let root ← Beam.resolveExistingPath <| System.FilePath.mk root let leanPlugin? ← opts.leanPlugin?.mapM (fun path => Beam.resolveExistingPath <| System.FilePath.mk path) let config : BrokerConfig := { @@ -2444,20 +2893,7 @@ def main (args : List String) : IO Unit := do leanPlugin? := leanPlugin? rocqCmd? := opts.rocqCmd? } - let listener ← Transport.bindAndListen opts.endpoint 16 - let startMonoNanos ← IO.monoNanosNow - let runtime : ServerRuntime := { - state := ← Std.Mutex.new (mkInitialState config workspaceId startMonoNanos) - endpoint := opts.endpoint - stop := ← IO.mkRef false - activeRequests := ← ActiveRequestRegistry.create - } - let rootWatcher ← IO.asTask (prio := Task.Priority.dedicated) <| watchRoot runtime root - try - acceptLoop runtime listener - finally - runtime.stop.set true - Transport.closeListener listener - discard <| IO.wait rootWatcher + withDaemonResources opts config workspaceId mode root fun resources => + acceptLoop resources.runtime resources.transport end Beam.Broker diff --git a/Beam/Broker/Transport.lean b/Beam/Broker/Transport.lean index 669072cf..fff17e7a 100644 --- a/Beam/Broker/Transport.lean +++ b/Beam/Broker/Transport.lean @@ -17,6 +17,12 @@ namespace Beam.Broker.Transport open Std.Net open Std.Internal.UV +def maxFrameBytes : Nat := + 16 * 1024 * 1024 + +private def maxFrameHeaderBytes : Nat := + 20 + inductive Endpoint where | tcp (port : UInt16) deriving Repr, BEq @@ -39,7 +45,35 @@ private def waitTcpPromise (promise : IO.Promise (Except IO.Error α)) (failureM | throw <| IO.userError failureMessage match result with | .ok value => pure value - | .error err => throw <| IO.userError s!"{failureMessage}: {err}" + | .error err => throw err + +private inductive ReceiveWaitResult (α : Type) where + | completed (value : α) + | timedOut + +private partial def waitTcpReceivePromiseUntil + (client : TCP.Socket) + (promise : IO.Promise (Except IO.Error α)) + (deadlineNanos : Nat) + (failureMessage : String) + (pollMs : Nat := 10) : IO (ReceiveWaitResult α) := do + let resultTask := promise.result? + let rec loop : IO (ReceiveWaitResult α) := do + if ← IO.hasFinished resultTask then + let some result ← IO.wait resultTask + | throw <| IO.userError failureMessage + match result with + | .ok value => pure <| .completed value + | .error err => throw err + else if (← IO.monoNanosNow) >= deadlineNanos then + -- The caller abandons and closes this connection after timeout. Cancel the exact pending UV + -- receive first so no read remains attached to the socket. + TCP.Socket.cancelRecv client + pure .timedOut + else + IO.sleep pollMs.toUInt32 + loop + loop def connect (endpoint : Endpoint) : IO Connection := do match endpoint with @@ -75,38 +109,74 @@ def closeConnection (conn : Connection) : IO Unit := do def closeListener (listener : Listener) : IO Unit := do match listener with - | .tcp _ => - pure () + | .tcp server => + try + TCP.Socket.cancelAccept server + catch _ => + pure () private def sendMsgTcp (client : TCP.Socket) (msg : String) : IO Unit := do let bytes := msg.toUTF8 + if bytes.size > maxFrameBytes then + throw <| IO.userError s!"Beam daemon frame exceeds {maxFrameBytes} bytes" let header := s!"{bytes.size}\n".toUTF8 let promise ← TCP.Socket.send client #[header, bytes] waitTcpPromise promise "Beam daemon connection closed before TCP send completed" -private def recvMsgTcp (client : TCP.Socket) : IO String := do +private def receiveTcp (client : TCP.Socket) (size : UInt64) : IO (ReceiveWaitResult (Option ByteArray)) := do + let promise ← TCP.Socket.recv? client size + .completed <$> waitTcpPromise promise "Beam daemon connection closed during TCP receive" + +private def receiveTcpUntil + (client : TCP.Socket) + (size : UInt64) + (deadlineNanos : Nat) : IO (ReceiveWaitResult (Option ByteArray)) := do + let promise ← TCP.Socket.recv? client size + waitTcpReceivePromiseUntil client promise deadlineNanos + "Beam daemon connection closed during TCP receive" + +private def recvMsgTcpUsing + (receive : UInt64 → IO (ReceiveWaitResult (Option ByteArray))) : IO (Option String) := do let mut header := ByteArray.empty repeat - let promise ← TCP.Socket.recv? client 1 - let some chunk ← waitTcpPromise promise "Beam daemon connection closed during TCP receive" - | throw <| IO.userError "Beam daemon connection closed" - if chunk[0]! == '\n'.toUInt8 then - break - header := header ++ chunk + match ← receive 1 with + | .timedOut => return none + | .completed none => throw <| IO.userError "Beam daemon connection closed" + | .completed (some chunk) => + if chunk.isEmpty then + throw <| IO.userError "Beam daemon received an empty header chunk" + if chunk[0]! == '\n'.toUInt8 then + break + if header.size >= maxFrameHeaderBytes then + throw <| IO.userError "Beam daemon frame header is too long" + header := header ++ chunk let some lenStr := String.fromUTF8? header | throw <| IO.userError "invalid Beam daemon header" let some len := lenStr.toNat? | throw <| IO.userError "invalid Beam daemon length" + if len > maxFrameBytes then + throw <| IO.userError s!"Beam daemon frame exceeds {maxFrameBytes} bytes" let mut payload := ByteArray.empty while payload.size < len do - let promise ← TCP.Socket.recv? client (len - payload.size).toUInt64 - let some chunk ← waitTcpPromise promise "Beam daemon connection closed during TCP receive" - | throw <| IO.userError "Beam daemon connection closed" - payload := payload ++ chunk + match ← receive (len - payload.size).toUInt64 with + | .timedOut => return none + | .completed none => throw <| IO.userError "Beam daemon connection closed" + | .completed (some chunk) => + if chunk.isEmpty then + throw <| IO.userError "Beam daemon received an empty payload chunk" + payload := payload ++ chunk let some msg := String.fromUTF8? payload | throw <| IO.userError "invalid Beam daemon UTF-8" + pure (some msg) + +private def recvMsgTcp (client : TCP.Socket) : IO String := do + let some msg ← recvMsgTcpUsing (receiveTcp client) + | throw <| IO.userError "unbounded Beam daemon receive timed out" pure msg +private def recvMsgTcpUntil (client : TCP.Socket) (deadlineNanos : Nat) : IO (Option String) := do + recvMsgTcpUsing (receiveTcpUntil client · deadlineNanos) + def sendMsg (conn : Connection) (msg : String) : IO Unit := do match conn with | .tcp client => sendMsgTcp client msg @@ -115,4 +185,9 @@ def recvMsg (conn : Connection) : IO String := do match conn with | .tcp client => recvMsgTcp client +/-- Receive one framed message by an absolute monotonic deadline, returning `none` on timeout. -/ +def recvMsgUntil (conn : Connection) (deadlineNanos : Nat) : IO (Option String) := do + match conn with + | .tcp client => recvMsgTcpUntil client deadlineNanos + end Beam.Broker.Transport diff --git a/Beam/BrokerClient.lean b/Beam/BrokerClient.lean index 553db0ef..c8a74911 100644 --- a/Beam/BrokerClient.lean +++ b/Beam/BrokerClient.lean @@ -22,8 +22,12 @@ private def usage : String := String.intercalate "\n" [ "usage: beam-client [--port N] request | request-stream ", "", + "beam-client is raw port-oriented maintainer/debug tooling.", + "For wrapper sessions, use: lean-beam --root PATH [--control-dir DIR] request-stream ", + "That supported machine interface selects the session descriptor and injects routing/authentication.", + "", "request prints the final response on stdout and formats streamed diagnostics for humans on stderr.", - "request-stream is the preferred machine interface: it prints one compact StreamMessage JSON line", + "raw request-stream prints one compact StreamMessage JSON line", "per event on stdout using kind + payload + optional clientRequestId; kinds are", "diagnostic | fileProgress | response, and the final response is last." ] @@ -41,9 +45,9 @@ private def parseRequestArg (json : String) : IO Request := do private def parseRequest (args : List String) : IO (ClientMode × Request) := do match args with - | "request" :: json :: _ => + | ["request", json] => pure (.request, ← parseRequestArg json) - | "request-stream" :: json :: _ => + | ["request-stream", json] => pure (.requestStream, ← parseRequestArg json) | _ => throw <| IO.userError usage diff --git a/Beam/Cli/Args.lean b/Beam/Cli/Args.lean index 41db72f7..47a30cd1 100644 --- a/Beam/Cli/Args.lean +++ b/Beam/Cli/Args.lean @@ -17,6 +17,7 @@ open Beam.Broker structure CliOptions where explicitRoot? : Option System.FilePath := none + explicitControlDir? : Option System.FilePath := none requestedPort? : Option UInt16 := none args : List String := [] @@ -38,7 +39,7 @@ def hasSubstring (text needle : String) : Bool := | _ => true def textArgUsage (cmdHead : String) : String := - s!"usage: beam [--root PATH] [--port N] {cmdHead} [--stdin | --text-file | -- | ]" + s!"usage: beam [--root PATH] {cmdHead} [--stdin | --text-file | -- | ]" def textArgReadsStdin (args : List String) : Bool := match args with @@ -67,7 +68,7 @@ def parseJsonText (label text : String) : IO Json := do | .error err => throw <| IO.userError s!"invalid {label}: {err}" def handleArgUsage (cmdHead : String) : String := - s!"usage: beam [--root PATH] [--port N] {cmdHead} >" + s!"usage: beam [--root PATH] {cmdHead} >" def handleArgReadsStdin (args : List String) : Bool := match args with @@ -114,7 +115,7 @@ def parseHandleInput (cmdHead : String) (args : List String) : IO (Handle × Lis private def parseLeanDiagnosticScopeArgs (command : String) (args : List String) : IO Beam.Broker.DiagnosticScope := - let usage := s!"usage: beam [--root PATH] [--port N] {command} [+all-diagnostics]" + let usage := s!"usage: beam [--root PATH] {command} [+all-diagnostics]" match args with | [] => pure Beam.Broker.DiagnosticScope.errors | ["+all-diagnostics"] => pure Beam.Broker.DiagnosticScope.all @@ -133,7 +134,7 @@ def parseLeanCloseSaveArgs (args : List String) : IO Beam.Broker.DiagnosticScope parseLeanDiagnosticScopeArgs "lean-close-save" args def leanReferencesUsage : String := - "usage: beam [--root PATH] [--port N] lean-references [--include-declaration|--exclude-declaration]" + "usage: beam [--root PATH] lean-references [--include-declaration|--exclude-declaration]" def parseLeanReferencesArgs (args : List String) : IO Bool := do match args with @@ -143,7 +144,7 @@ def parseLeanReferencesArgs (args : List String) : IO Bool := do | _ => throw <| IO.userError leanReferencesUsage def leanGoalsUsage : String := - "usage: beam [--root PATH] [--port N] lean-goals before|after " + "usage: beam [--root PATH] lean-goals before|after " def parseLeanGoalsModeArg (mode : String) : IO GoalMode := do match mode with @@ -166,7 +167,7 @@ private def parseTodoSuggestArg (value : String) : IO Beam.LSP.Todo.TodoSuggestM throw <| IO.userError s!"invalid todo suggest mode '{value}' (expected one of: {allowed}): {err}" def leanTodoUsage : String := - "usage: beam [--root PATH] [--port N] lean-todo [--kind ...] [--suggest none|basic]" + "usage: beam [--root PATH] lean-todo [--kind ...] [--suggest none|basic]" def parseLeanTodoArgs (args : List String) : IO (Option (Array Beam.LSP.Todo.TodoKind) × Option Beam.LSP.Todo.TodoSuggestMode) := do @@ -208,11 +209,23 @@ private def resolveExplicitRootArg (root : String) : IO System.FilePath := do catch err => throw <| IO.userError s!"workspace root does not resolve: {err.toString}" +private def resolveControlDirArg (dir : String) : IO System.FilePath := do + let path := System.FilePath.mk dir + if ← path.pathExists then + Beam.resolveExistingPath path + else + let cwd ← IO.currentDir + let absolute := if path.isAbsolute then path else cwd / path + pure absolute.normalize + partial def parseCliOptions (opts : CliOptions) : List String → IO CliOptions | [] => pure opts | "--root" :: root :: rest => do let root ← resolveExplicitRootArg root parseCliOptions { opts with explicitRoot? := some root } rest + | "--control-dir" :: dir :: rest => do + let dir ← resolveControlDirArg dir + parseCliOptions { opts with explicitControlDir? := some dir } rest | "--port" :: port :: rest => do let port ← IO.ofExcept <| parsePortText "port" port parseCliOptions { opts with requestedPort? := some port } rest diff --git a/Beam/Cli/Broker.lean b/Beam/Cli/Broker.lean index cd5bde41..1ce16619 100644 --- a/Beam/Cli/Broker.lean +++ b/Beam/Cli/Broker.lean @@ -14,37 +14,26 @@ namespace Beam.Cli open Beam.Broker -/-- -Address a request to the private workspace of the CLI's one-project daemon. - -Process-wide control operations deliberately remain unscoped. An explicitly supplied workspace is -preserved so this adapter does not rewrite lower-level test or maintenance requests. --/ -def inProjectDaemonWorkspace (req : Request) : Request := +private def inWorkspace (workspaceId : WorkspaceId) (req : Request) : Request := match req.op.workspaceScope with | .none => req | .optional | .required => if req.workspaceId?.isSome then req - else { req with workspaceId? := some projectDaemonWorkspaceId } + else { req with workspaceId? := some workspaceId } -def withBrokerErrorContext {α} (root : System.FilePath) (action : IO α) : IO α := do - try - action - catch e => - throw <| IO.userError (← daemonFailureMessage root e.toString) - -def callBroker (root : System.FilePath) (endpoint : Transport.Endpoint) (req : Request) : IO Unit := - withBrokerErrorContext root do - let req ← withEnvClientRequestId (inProjectDaemonWorkspace req) - let resp ← sendRequest endpoint req - printResponse resp req.clientRequestId? - failOnError resp - -def callBrokerQuiet (root : System.FilePath) (endpoint : Transport.Endpoint) (req : Request) : IO Unit := - withBrokerErrorContext root do - let req ← withEnvClientRequestId (inProjectDaemonWorkspace req) - let resp ← sendRequest endpoint req - failOnError resp +/-- Address a wrapper request to the workspace selected from its session descriptor. -/ +def inSelectedDaemonWorkspace (client : ProjectDaemonClient) (req : Request) : Request := + inWorkspace client.workspaceId req + +private def withBrokerErrorContext + {α} + (root : System.FilePath) + (client : ProjectDaemonClient) + (action : IO (Except BrokerClientFailure α)) : IO α := do + match ← action with + | .ok value => pure value + | .error failure => + throw <| IO.userError (← daemonFailureMessage root failure (some client.controlDir)) structure BrokerWaitSpec where action : String @@ -55,17 +44,44 @@ structure BrokerWaitSpec where failureBoundary : String := "before the request completed" responseNote? : Response → Option String := fun _ => none -private structure InterruptWatcher where - signal : Std.Internal.UV.Signal - task : Task (Except IO.Error Unit) +structure InterruptWatcher where + interrupted : IO Bool + awaitInterrupt : IO Unit -private def InterruptWatcher.stop (watcher : InterruptWatcher) : IO Unit := - Std.Internal.UV.Signal.stop watcher.signal +private def closeInterruptSignal (signal : Std.Internal.UV.Signal) : IO Unit := do + -- `Signal.stop` alone leaves an unresolved `next` promise and its waiter leaked. Cancel the + -- pending wait first, then stop the underlying signal resource. + try + Std.Internal.UV.Signal.cancel signal + finally + Std.Internal.UV.Signal.stop signal -private def InterruptWatcher.interrupted (watcher : InterruptWatcher) : IO Bool := - IO.hasFinished watcher.task +/-- Acquire one non-repeating SIGINT watcher and release its pending wait on every exit path. -/ +def withInterruptWatcher (act : InterruptWatcher → IO α) : IO α := do + let signal ← Std.Internal.UV.Signal.mk 2 false + let promise ← + try + Std.Internal.UV.Signal.next signal + catch err => + try + Std.Internal.UV.Signal.stop signal + catch _ => + pure () + throw err + let event := promise.result? + let watcher : InterruptWatcher := { + interrupted := IO.hasFinished event + awaitInterrupt := do + let some _ ← IO.wait event + | throw <| IO.userError "SIGINT watcher promise dropped" + pure () + } + try + act watcher + finally + closeInterruptSignal signal -def progressEnabled : IO Bool := do +private def progressEnabled : IO Bool := do match ← envFlag? "BEAM_PROGRESS" with | some enabled => pure enabled @@ -74,6 +90,7 @@ def progressEnabled : IO Bool := do private structure WrapperBrokerRequest where request : Request + clientRequestId : String visibleClientRequestId? : Option String private def mkWrapperClientRequestId (req : Request) : IO String := do @@ -87,113 +104,137 @@ private def withWrapperClientRequestId (req : Request) : IO WrapperBrokerRequest | some clientRequestId => pure { request := req + clientRequestId visibleClientRequestId? := some clientRequestId } | none => let clientRequestId ← mkWrapperClientRequestId req pure { request := { req with clientRequestId? := some clientRequestId } + clientRequestId visibleClientRequestId? := none } -private def mkInterruptWatcher? (clientRequestId? : Option String) : IO (Option InterruptWatcher) := do - match clientRequestId? with - | none => pure none - | some _ => - let signal ← Std.Internal.UV.Signal.mk 2 false - let promise ← Std.Internal.UV.Signal.next signal - let task ← IO.asTask (prio := Task.Priority.dedicated) do - let some _ ← IO.wait promise.result? - | throw <| IO.userError "SIGINT watcher promise dropped" - pure () - pure <| some { signal, task } +private def prepareWrapperBrokerRequest + (client : ProjectDaemonClient) + (req : Request) : IO WrapperBrokerRequest := do + let wrapper ← withWrapperClientRequestId <| inSelectedDaemonWorkspace client req + pure { wrapper with request := client.authorize wrapper.request } def decodeCancelAcknowledged? (resp : Response) : Option Bool := do let result ← resp.result? result.getObjValAs? Bool "cancelled" |>.toOption private def sendBrokerCancellation - (endpoint : Transport.Endpoint) - (req : Request) : IO (Option Bool) := do + (client : ProjectDaemonClient) + (clientRequestId : String) : IO (Option Bool) := do let cancelReq : Request := { op := .cancel - cancelRequestId? := req.clientRequestId? + workspaceId? := some client.workspaceId + cancelRequestId? := some clientRequestId } try - let resp ← sendRequest endpoint (← withEnvClientRequestId cancelReq) + let req ← withEnvClientRequestId cancelReq + let resp ← sendRequest client.endpoint (client.authorize req) pure <| decodeCancelAcknowledged? resp catch _ => pure none private def awaitBrokerResponse - (task : Task (Except IO.Error Response)) - (endpoint : Transport.Endpoint) - (req : Request) + (task : Task (Except IO.Error (Except BrokerClientFailure Response))) + (client : ProjectDaemonClient) + (clientRequestId : String) (visibleClientRequestId? : Option String) - (spec : BrokerWaitSpec) - (interruptWatcher? : Option InterruptWatcher) - (showProgress : Bool) : IO Response := do + (progressSpec? : Option BrokerWaitSpec) + (interruptWatcher : InterruptWatcher) : IO (Except BrokerClientFailure Response) := do let mut interruptObserved := false let mut cancelAcknowledged := false let emit := fun msg => IO.eprintln <| annotateRunatMessage visibleClientRequestId? msg - if showProgress then + if let some spec := progressSpec? then emit spec.startMsg let mut waitedMs := 0 - try - while !(← IO.hasFinished task) do - match interruptWatcher? with - | some watcher => - if (← watcher.interrupted) then - if !interruptObserved then - interruptObserved := true - emit "beam: requesting broker cancellation" - if !cancelAcknowledged then - -- SIGINT can arrive after the wrapper starts the request task but before the broker - -- has registered the client request id as active. Retry until the broker acknowledges - -- cancellation or the original request finishes. - match ← sendBrokerCancellation endpoint req with - | some true => cancelAcknowledged := true - | some false | none => pure () - else - pure () - | none => - pure () - IO.sleep 500 - if !(← IO.hasFinished task) then - waitedMs := waitedMs + 500 - if showProgress && waitedMs % 1000 == 0 then + while !(← IO.hasFinished task) do + let signalInterrupted ← interruptWatcher.interrupted + if signalInterrupted || (← IO.checkCanceled) then + if !interruptObserved then + interruptObserved := true + emit "beam: requesting broker cancellation" + if !cancelAcknowledged then + -- SIGINT can arrive after the wrapper starts the request task but before the broker + -- has registered the client request id as active. Retry until the broker acknowledges + -- cancellation or the original request finishes. + match ← sendBrokerCancellation client clientRequestId with + | some true => cancelAcknowledged := true + | some false | none => pure () + IO.sleep 500 + if !(← IO.hasFinished task) then + waitedMs := waitedMs + 500 + if waitedMs % 1000 == 0 then + if let some spec := progressSpec? then emit <| spec.stillWaitingMsg (waitedMs / 1000) - let resp ← - match (← IO.wait task) with - | .ok resp => pure resp - | .error err => throw err - if showProgress then - emit <| spec.completeMsg resp - pure resp - finally - match interruptWatcher? with - | some watcher => watcher.stop - | none => pure () + let result ← + match (← IO.wait task) with + | .ok result => pure result + | .error err => throw err + match result with + | .ok response => + if let some spec := progressSpec? then + emit <| spec.completeMsg response + pure <| .ok response + | .error failure => + pure <| .error failure private def awaitBrokerResponseWithInterrupts - (endpoint : Transport.Endpoint) - (req : Request) + (client : ProjectDaemonClient) + (clientRequestId : String) (visibleClientRequestId? : Option String) - (spec : BrokerWaitSpec) - (showProgress : Bool) - (action : IO Response) : IO Response := do + (progressSpec? : Option BrokerWaitSpec) + (action : IO (Except BrokerClientFailure Response)) : + IO (Except BrokerClientFailure Response) := do -- Wrapper calls synthesize a broker clientRequestId when the user did not provide one. That id -- gives SIGINT cancellation a stable broker key but is kept out of the CLI's public output. - let interruptWatcher? ← mkInterruptWatcher? req.clientRequestId? - let task ← - try - IO.asTask (prio := Task.Priority.dedicated) action - catch e => - match interruptWatcher? with - | some watcher => watcher.stop - | none => pure () - throw e - awaitBrokerResponse task endpoint req visibleClientRequestId? spec interruptWatcher? showProgress + withInterruptWatcher fun interruptWatcher => do + let task ← IO.asTask (prio := Task.Priority.dedicated) action + awaitBrokerResponse task client clientRequestId visibleClientRequestId? progressSpec? + interruptWatcher + +private structure WrapperBrokerResponse where + response : Response + visibleClientRequestId? : Option String + +private def requestBrokerResponse + (root : System.FilePath) + (client : ProjectDaemonClient) + (req : Request) : IO WrapperBrokerResponse := do + let wrapperReq ← prepareWrapperBrokerRequest client req + let req := wrapperReq.request + let response ← withBrokerErrorContext root client do + awaitBrokerResponseWithInterrupts client wrapperReq.clientRequestId + wrapperReq.visibleClientRequestId? none <| + sendRequestWithCallbacksResult client.endpoint req + pure { response, visibleClientRequestId? := wrapperReq.visibleClientRequestId? } + +/-- Send one wrapper request without printing or interpreting its response. -/ +def requestBroker + (root : System.FilePath) + (client : ProjectDaemonClient) + (req : Request) : IO Response := do + pure (← requestBrokerResponse root client req).response + +def callBroker + (root : System.FilePath) + (client : ProjectDaemonClient) + (req : Request) : IO Unit := do + let result ← requestBrokerResponse root client req + printResponse result.response result.visibleClientRequestId? + failOnError result.response + +def callBrokerQuiet + (root : System.FilePath) + (client : ProjectDaemonClient) + (req : Request) : IO Unit := do + let resp ← requestBroker root client req + failOnError resp private def syncReadinessSuffix (result : SyncFileResult) : String := let readiness := result.readiness @@ -392,40 +433,42 @@ def leanSaveWaitSpec def callBrokerWithProgress (root : System.FilePath) - (endpoint : Transport.Endpoint) + (client : ProjectDaemonClient) (req : Request) - (spec : BrokerWaitSpec) : IO Unit := - withBrokerErrorContext root do - let wrapperReq ← withWrapperClientRequestId (inProjectDaemonWorkspace req) - let req := wrapperReq.request - let visibleClientRequestId? := wrapperReq.visibleClientRequestId? - let showProgress ← progressEnabled - let callbacks : StreamCallbacks := { - onFileProgress := fun _ progress => do - if showProgress then - IO.eprintln <| annotateRunatMessage visibleClientRequestId? (spec.progressMsg progress) - onDiagnostic := fun _ diagnostic => - IO.eprintln <| annotateRunatMessage visibleClientRequestId? (formatStreamDiagnostic diagnostic) - } - let resp ← awaitBrokerResponseWithInterrupts endpoint req visibleClientRequestId? spec showProgress <| - sendRequestWithCallbacks endpoint req callbacks - match responseErrorSummary? spec.action spec.failureBoundary resp with - | some note => - IO.eprintln <| annotateRunatMessage visibleClientRequestId? note - | none => - pure () - match responseRecoveryHint? resp with - | some note => - IO.eprintln <| annotateRunatMessage visibleClientRequestId? note - | none => - pure () - match spec.responseNote? resp with - | some note => - IO.eprintln <| annotateRunatMessage visibleClientRequestId? note - | none => - pure () - maybeEmitLiteralBackslashNewlineHint visibleClientRequestId? req resp - printResponse resp visibleClientRequestId? - failOnError resp + (spec : BrokerWaitSpec) : IO Unit := do + let wrapperReq ← prepareWrapperBrokerRequest client req + let req := wrapperReq.request + let visibleClientRequestId? := wrapperReq.visibleClientRequestId? + let showProgress ← progressEnabled + let callbacks : StreamCallbacks := { + onFileProgress := fun _ progress => do + if showProgress then + IO.eprintln <| annotateRunatMessage visibleClientRequestId? (spec.progressMsg progress) + onDiagnostic := fun _ diagnostic => + IO.eprintln <| annotateRunatMessage visibleClientRequestId? (formatStreamDiagnostic diagnostic) + } + let progressSpec? := if showProgress then some spec else none + let resp ← withBrokerErrorContext root client do + awaitBrokerResponseWithInterrupts client wrapperReq.clientRequestId + visibleClientRequestId? progressSpec? <| + sendRequestWithCallbacksResult client.endpoint req callbacks + match responseErrorSummary? spec.action spec.failureBoundary resp with + | some note => + IO.eprintln <| annotateRunatMessage visibleClientRequestId? note + | none => + pure () + match responseRecoveryHint? resp with + | some note => + IO.eprintln <| annotateRunatMessage visibleClientRequestId? note + | none => + pure () + match spec.responseNote? resp with + | some note => + IO.eprintln <| annotateRunatMessage visibleClientRequestId? note + | none => + pure () + maybeEmitLiteralBackslashNewlineHint visibleClientRequestId? req resp + printResponse resp visibleClientRequestId? + failOnError resp end Beam.Cli diff --git a/Beam/Cli/Commands.lean b/Beam/Cli/Commands.lean index a73c6743..8e637307 100644 --- a/Beam/Cli/Commands.lean +++ b/Beam/Cli/Commands.lean @@ -12,11 +12,9 @@ import Beam.Cli.Feedback import Beam.Cli.Info import Beam.Cli.InstallPrune import Beam.Cli.LeanOperation -import Beam.Cli.Lock import Beam.Cli.Project import Beam.Cli.RuntimeBundle import Beam.Cli.Usage -import Std.Internal.UV.Signal open Lean @@ -31,12 +29,12 @@ private def wrapperDisplayAction (fallback : String) : IO String := do private def updateVersionForRocqGoals (root : System.FilePath) - (endpoint : Transport.Endpoint) + (client : ProjectDaemonClient) (path : String) : IO Nat := do - let resp ← sendRequest endpoint { + let resp ← requestBroker root client { op := .updateFile backend := .rocq - workspaceId? := some projectDaemonWorkspaceId + workspaceId? := some client.workspaceId root? := some root.toString path? := some path } @@ -46,25 +44,22 @@ private def updateVersionForRocqGoals pure result.version private def runLeanRunAt - (home : System.FilePath) (opts : CliOptions) (action path versionText lineText characterText : String) (textArgs : List String) (storeHandle : Bool := false) : IO Unit := do let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let version ← parseNatArg "version" versionText let line ← parseNatArg "line" lineText let character ← parseNatArg "character" characterText let parsedText ← parseTextArg s!"{action} " textArgs - withWrapperLease root daemon.startedNew do + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => do let req ← withEnvClientRequestId <| leanRunAtRequest root path version line character parsedText.text? (storeHandle := storeHandle) maybeEmitTextDebug req.clientRequestId? action parsedText.source parsedText.text? - callBrokerWithProgress root daemon.endpoint req (leanRunAtWaitSpec action path line character) + callBrokerWithProgress root client req (leanRunAtWaitSpec action path line character) private def runLeanRunWith - (home : System.FilePath) (opts : CliOptions) (action path : String) (args : List String) @@ -80,70 +75,95 @@ private def runLeanRunWith "cannot read both handle json and continuation text from stdin; pass the handle inline, use --handle-file, or use --text-file for the text" ] let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let (handle, textArgs) ← parseHandleInput s!"{action} " args let parsedText ← parseTextArg s!"{action} >" textArgs let req ← withEnvClientRequestId <| leanRunWithRequest root path handle parsedText.text? (linear := linear) maybeEmitTextDebug req.clientRequestId? action parsedText.source parsedText.text? - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint req (leanRunWithWaitSpec path (linear := linear)) + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client req (leanRunWithWaitSpec path (linear := linear)) private def runLeanRelease - (home : System.FilePath) (opts : CliOptions) (action : String) (path : String) (args : List String) : IO Unit := do let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let (handle, extra) ← parseHandleInput s!"{action} " args unless extra.isEmpty do throw <| IO.userError (handleArgUsage s!"{action} ") - withWrapperLease root daemon.startedNew do - callBroker root daemon.endpoint <| leanReleaseRequest root path handle + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBroker root client <| leanReleaseRequest root path handle private def shutdownProjectDaemon (opts : CliOptions) : IO Unit := do let root ← projectRootAny opts - withProjectControlLock root do - match ← registryLiveFor root with - | some entry => - if let some endpoint := Beam.Daemon.registryEndpoint? entry then - let resp ← sendRequest endpoint { op := .shutdown } - printResponse resp - waitForPidGone entry.pid - if ← pidAlive entry.pid then - killPid entry.pid - waitForPidGone entry.pid - removeRegistry root - else - stopRegisteredDaemon root - printJsonLine <| Json.mkObj [ - ("result", Json.mkObj [("shutdown", toJson false), ("reason", toJson ("notFound" : String))]) - ] - | none => - stopRegisteredDaemon root - printJsonLine <| Json.mkObj [ - ("result", Json.mkObj [("shutdown", toJson false), ("reason", toJson ("notFound" : String))]) - ] + match ← shutdownRegisteredProjectDaemon root opts.explicitControlDir? with + | .ok (some resp) => printResponse resp + | .ok none => + printJsonLine <| Json.mkObj [ + ("result", Json.mkObj [("shutdown", toJson false), ("reason", toJson ("notFound" : String))]) + ] + | .error failure => + throw <| IO.userError (← daemonFailureMessage root failure opts.explicitControlDir?) + +private def recoverProjectSession (opts : CliOptions) (args : List String) : IO Unit := do + let root ← projectRootAny opts + let (generation?, forceOpaque) ← + match args with + | ["--generation", generation] => pure (some generation, false) + | ["--force"] => pure (none, true) + | _ => + throw <| IO.userError + "usage: beam [--root PATH] [--control-dir DIR] recover --generation ID | --force" + let result ← recoverProjectDaemon root generation? forceOpaque opts.explicitControlDir? + printJsonLine (toJson result) + +private def parseProjectRequestArg (raw : String) : IO ProjectRequest := do + let text ← if raw == "-" then (← IO.getStdin).readToEnd else pure raw + let json ← parseJsonText "project request json" text + match fromJson? json with + | .ok request => pure request + | .error err => throw <| IO.userError s!"invalid project request payload: {err}" + +private def runProjectRequestStream (opts : CliOptions) (raw : String) : IO Unit := do + let some root := opts.explicitRoot? + | throw <| IO.userError + "request-stream is a machine interface and requires an explicit --root PATH" + let projectRequest ← parseProjectRequestArg raw + withSelectedProjectDaemon root (explicitControlDir? := opts.explicitControlDir?) fun selected => do + let req := projectRequest.attach selected.workspace.workspaceId selected.workspace.root + selected.client.capability + let resp ← sendRequestWithStream selected.client.endpoint req fun stream => + IO.println (toJson stream).compress + failOnError resp + +private def parseBackendName (name : String) : IO Backend := do + match fromJson? (Json.str name) with + | .ok backend => pure backend + | .error err => throw <| IO.userError err + +private def commandMaySelectPort : List String → Bool + | ["ensure", "--hold"] => true + | ["ensure", _, "--hold"] => true + | _ => false -private def backendOfName (name : String) : Backend := - if name == "rocq" then .rocq else .lean +private def validateRequestedPortScope (opts : CliOptions) : IO Unit := do + if opts.requestedPort?.isSome && !commandMaySelectPort opts.args then + throw <| IO.userError "--port is only valid when starting an owner with 'ensure [lean|rocq] --hold'" -private def runThenHoldUntilInterrupted (act : IO Unit) : IO Unit := do - let signal ← Std.Internal.UV.Signal.mk 2 false - let promise ← Std.Internal.UV.Signal.next signal - let task ← IO.asTask (prio := Task.Priority.dedicated) do - let some _ ← IO.wait promise.result? - | throw <| IO.userError "SIGINT watcher promise dropped" - pure () - try +private def runThenHoldUntilInterrupted + (owner : ProjectDaemonOwner) + (act : IO Unit) : IO Unit := + withInterruptWatcher fun watcher => do act - match ← IO.wait task with - | .ok () => pure () - | .error err => throw err - finally - Std.Internal.UV.Signal.stop signal + while !(← watcher.interrupted) && (← owner.exitCode?).isNone && + (← owner.registered) && !(← IO.checkCanceled) do + IO.sleep 50 + if ← watcher.interrupted then + watcher.awaitInterrupt + else if let some exitCode ← owner.exitCode? then + unless exitCode == 0 do + throw <| IO.userError s!"owned Beam daemon exited with status {exitCode}" private def ensureBackend (home : System.FilePath) @@ -151,19 +171,20 @@ private def ensureBackend (backend : Backend) (hold : Bool := false) : IO Unit := do let root ← projectRoot opts backend - let daemon ← ensureProjectDaemon home root backend opts - withWrapperLease root daemon.startedNew do - if hold then - runThenHoldUntilInterrupted do - callBroker root daemon.endpoint { + if hold then + withProjectDaemonOwner home root backend opts fun owner => + runThenHoldUntilInterrupted owner do + callBroker root owner.client { op := .ensure, backend := backend, root? := some root.toString } (← IO.getStdout).flush - IO.eprintln "beam: holding ensured daemon; interrupt this wrapper process when finished" - else - callBroker root daemon.endpoint { op := .ensure, backend := backend, root? := some root.toString } + IO.eprintln "beam: owning Beam session; interrupt this wrapper process when finished" + else + withProjectDaemon root backend (explicitControlDir? := opts.explicitControlDir?) fun client => + callBroker root client { op := .ensure, backend := backend, root? := some root.toString } def runCommand (home : System.FilePath) (opts : CliOptions) : IO Unit := do + validateRequestedPortScope opts match opts.args with | [] => throw <| IO.userError usage @@ -199,95 +220,87 @@ def runCommand (home : System.FilePath) (opts : CliOptions) : IO Unit := do | "ensure" :: "--hold" :: [] => ensureBackend home opts .lean (hold := true) | "ensure" :: backend :: [] => - ensureBackend home opts (backendOfName backend) + ensureBackend home opts (← parseBackendName backend) | "ensure" :: backend :: "--hold" :: [] => - ensureBackend home opts (backendOfName backend) (hold := true) + ensureBackend home opts (← parseBackendName backend) (hold := true) | "lean-run-at" :: path :: version :: line :: character :: text => - runLeanRunAt home opts (← wrapperDisplayAction "lean-run-at") path version line character text + runLeanRunAt opts (← wrapperDisplayAction "lean-run-at") path version line character text | "lean-run-at-handle" :: path :: version :: line :: character :: text => - runLeanRunAt home opts (← wrapperDisplayAction "lean-run-at-handle") path version line character text + runLeanRunAt opts (← wrapperDisplayAction "lean-run-at-handle") path version line character text (storeHandle := true) | "lean-hover" :: path :: versionText :: line :: character :: [] => let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let version ← parseNatArg "version" versionText let line ← parseNatArg "line" line let character ← parseNatArg "character" character let action ← wrapperDisplayAction "lean-hover" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanHoverRequest root path version line character) (leanHoverWaitSpec path line character action) | "lean-signature-help" :: path :: versionText :: line :: character :: [] => let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let version ← parseNatArg "version" versionText let line ← parseNatArg "line" line let character ← parseNatArg "character" character let action ← wrapperDisplayAction "lean-signature-help" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanSignatureHelpRequest root path version line character) (leanSignatureHelpWaitSpec path line character action) | "lean-definition" :: path :: versionText :: line :: character :: [] => let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let version ← parseNatArg "version" versionText let line ← parseNatArg "line" line let character ← parseNatArg "character" character let action ← wrapperDisplayAction "lean-definition" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanDefinitionRequest root path version line character) (leanDefinitionWaitSpec path line character action) | "lean-references" :: path :: versionText :: line :: character :: extra => let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let version ← parseNatArg "version" versionText let line ← parseNatArg "line" line let character ← parseNatArg "character" character let includeDeclaration ← parseLeanReferencesArgs extra let action ← wrapperDisplayAction "lean-references" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanReferencesRequest root path version line character includeDeclaration) (leanReferencesWaitSpec path line character action) | "lean-document-symbols" :: path :: versionText :: [] => let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let version ← parseNatArg "version" versionText let action ← wrapperDisplayAction "lean-document-symbols" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanDocumentSymbolsRequest root path version) (leanDocumentSymbolsWaitSpec path action) | "lean-workspace-symbols" :: queryParts => let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let query ← match joinTextArgs queryParts with | some query => pure query - | none => throw <| IO.userError "usage: beam [--root PATH] [--port N] lean-workspace-symbols " + | none => throw <| IO.userError "usage: beam [--root PATH] lean-workspace-symbols " let action ← wrapperDisplayAction "lean-workspace-symbols" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanWorkspaceSymbolsRequest root query) (leanWorkspaceSymbolsWaitSpec query action) | "lean-goals" :: modeText :: path :: versionText :: line :: character :: [] => let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let mode ← parseLeanGoalsModeArg modeText let version ← parseNatArg "version" versionText let line ← parseNatArg "line" line let character ← parseNatArg "character" character let action ← wrapperDisplayAction "lean-goals" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanGoalsRequest root path version line character mode) (leanGoalsWaitSpec path line character mode (some action)) | "lean-todo" :: path :: versionText :: startLine :: startCharacter :: endLine :: endCharacter :: extra => do let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let version ← parseNatArg "version" versionText let startLine ← parseNatArg "startLine" startLine let startCharacter ← parseNatArg "startCharacter" startCharacter @@ -295,69 +308,62 @@ def runCommand (home : System.FilePath) (opts : CliOptions) : IO Unit := do let endCharacter ← parseNatArg "endCharacter" endCharacter let (kinds?, suggest?) ← parseLeanTodoArgs extra let action ← wrapperDisplayAction "lean-todo" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanTodoRequest root path version startLine startCharacter endLine endCharacter kinds? suggest?) (leanTodoWaitSpec path startLine startCharacter endLine endCharacter action) | "lean-run-with" :: path :: args => - runLeanRunWith home opts (← wrapperDisplayAction "lean-run-with") path args + runLeanRunWith opts (← wrapperDisplayAction "lean-run-with") path args | "lean-run-with-linear" :: path :: args => - runLeanRunWith home opts (← wrapperDisplayAction "lean-run-with-linear") path args + runLeanRunWith opts (← wrapperDisplayAction "lean-run-with-linear") path args (linear := true) | "lean-release" :: path :: args => - runLeanRelease home opts (← wrapperDisplayAction "lean-release") path args + runLeanRelease opts (← wrapperDisplayAction "lean-release") path args | "lean-save" :: path :: extra => do let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let diagnosticScope ← parseLeanSaveArgs extra let action ← wrapperDisplayAction "lean-save" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanSaveRequest root path diagnosticScope) (leanSaveWaitSpec path (action? := some action)) | "lean-update" :: path :: [] => let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts - withWrapperLease root daemon.startedNew do - callBroker root daemon.endpoint <| leanUpdateRequest root path + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBroker root client <| leanUpdateRequest root path | "lean-sync" :: path :: extra => do let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let diagnosticScope ← parseLeanSyncArgs extra let action ← wrapperDisplayAction "lean-sync" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanSyncRequest root path diagnosticScope) (syncWaitSpec path action) | "lean-refresh" :: path :: extra => do let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let diagnosticScope ← parseLeanRefreshArgs extra let action ← wrapperDisplayAction "lean-refresh" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanRefreshRequest root path diagnosticScope) (refreshWaitSpec path action) | "lean-close" :: path :: [] => let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts - withWrapperLease root daemon.startedNew do - callBroker root daemon.endpoint <| leanCloseRequest root path + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBroker root client <| leanCloseRequest root path | "lean-close-save" :: path :: extra => let root ← projectRoot opts .lean - let daemon ← ensureProjectDaemon home root .lean opts let diagnosticScope ← parseLeanCloseSaveArgs extra let action ← wrapperDisplayAction "lean-close-save" - withWrapperLease root daemon.startedNew do - callBrokerWithProgress root daemon.endpoint + withProjectDaemon root .lean (explicitControlDir? := opts.explicitControlDir?) fun client => + callBrokerWithProgress root client (leanCloseSaveRequest root path diagnosticScope) (leanSaveWaitSpec path (closeAfter := true) (action? := some action)) | "rocq-goals-after" :: path :: line :: character :: text => let root ← projectRoot opts .rocq - let daemon ← ensureProjectDaemon home root .rocq opts - withWrapperLease root daemon.startedNew do - let version ← updateVersionForRocqGoals root daemon.endpoint path - callBroker root daemon.endpoint { + withProjectDaemon root .rocq (explicitControlDir? := opts.explicitControlDir?) fun client => do + let version ← updateVersionForRocqGoals root client path + callBroker root client { op := .goals backend := .rocq root? := some root.toString @@ -372,10 +378,9 @@ def runCommand (home : System.FilePath) (opts : CliOptions) : IO Unit := do } | "rocq-goals-prev" :: path :: line :: character :: text => let root ← projectRoot opts .rocq - let daemon ← ensureProjectDaemon home root .rocq opts - withWrapperLease root daemon.startedNew do - let version ← updateVersionForRocqGoals root daemon.endpoint path - callBroker root daemon.endpoint { + withProjectDaemon root .rocq (explicitControlDir? := opts.explicitControlDir?) fun client => do + let version ← updateVersionForRocqGoals root client path + callBroker root client { op := .goals backend := .rocq root? := some root.toString @@ -389,43 +394,35 @@ def runCommand (home : System.FilePath) (opts : CliOptions) : IO Unit := do text? := joinTextArgs text } | "doctor" :: backend :: [] => - doctor home opts (if backend == "rocq" then .rocq else .lean) + doctor home opts (← parseBackendName backend) | "open-files" :: [] => let root ← projectRootAny opts - let entry ← lookupProjectDaemon root - if let some endpoint := Beam.Daemon.registryEndpoint? entry then - callBroker root endpoint { + withExistingProjectDaemon root (explicitControlDir? := opts.explicitControlDir?) fun client => + callBroker root client { op := .openDocs root? := some root.toString } - else - throw <| IO.userError s!"invalid Beam daemon endpoint registry for {entry.root}" | "cancel" :: requestId :: [] => let root ← projectRootAny opts - let entry ← lookupProjectDaemon root - if let some endpoint := Beam.Daemon.registryEndpoint? entry then - callBroker root endpoint { + withExistingProjectDaemon root (explicitControlDir? := opts.explicitControlDir?) fun client => + callBroker root client { op := .cancel cancelRequestId? := some requestId } - else - throw <| IO.userError s!"invalid Beam daemon endpoint registry for {entry.root}" | "stats" :: [] => let root ← projectRootAny opts - let entry ← lookupProjectDaemon root - if let some endpoint := Beam.Daemon.registryEndpoint? entry then - callBroker root endpoint { op := .stats } - else - throw <| IO.userError s!"invalid Beam daemon endpoint registry for {entry.root}" + withExistingProjectDaemon root (explicitControlDir? := opts.explicitControlDir?) fun client => + callBroker root client { op := .stats } | "reset-stats" :: [] => let root ← projectRootAny opts - let entry ← lookupProjectDaemon root - if let some endpoint := Beam.Daemon.registryEndpoint? entry then - callBroker root endpoint { op := .resetStats } - else - throw <| IO.userError s!"invalid Beam daemon endpoint registry for {entry.root}" + withExistingProjectDaemon root (explicitControlDir? := opts.explicitControlDir?) fun client => + callBroker root client { op := .resetStats } | "shutdown" :: [] => shutdownProjectDaemon opts + | "recover" :: args => + recoverProjectSession opts args + | "request-stream" :: raw :: [] => + runProjectRequestStream opts raw | _ => throw <| IO.userError usage diff --git a/Beam/Cli/DaemonManager.lean b/Beam/Cli/DaemonManager.lean index a955d167..fbff39e0 100644 --- a/Beam/Cli/DaemonManager.lean +++ b/Beam/Cli/DaemonManager.lean @@ -11,6 +11,8 @@ import Beam.Cli.Args import Beam.Cli.Lock import Beam.Cli.Project import Beam.Daemon.Debug +import Beam.Daemon.Paths +import Beam.Daemon.Registry open Lean @@ -23,9 +25,6 @@ open Beam.Daemon def projectDaemonWorkspaceId : WorkspaceId := "beam-cli-project" -def controlDir (root : System.FilePath) : IO System.FilePath := do - Beam.Daemon.controlDir root - private def defaultProjectControlLockTimeoutMs : Nat := 60000 @@ -42,9 +41,6 @@ private def projectControlLockTimeoutMs : IO Nat := do "invalid BEAM_CONTROL_LOCK_TIMEOUT_MS value '0': expected a positive timeout" pure timeoutMs -def projectControlLockDir (root : System.FilePath) : IO System.FilePath := do - pure ((← controlDir root) / "lock") - /-- Run `act` while holding the per-project daemon control lock. @@ -52,14 +48,135 @@ Project control operations should fail with owner diagnostics instead of waiting live but stuck wrapper process. Longer bundle build locks intentionally use the lower-level unbounded lock helper. -/ -def withProjectControlLock (root : System.FilePath) (act : IO α) : IO α := do - withLockTimeout (← projectControlLockDir root) (← projectControlLockTimeoutMs) act +private structure ProjectControl where + root : System.FilePath + dir : System.FilePath + registry : System.FilePath + +private def projectControl + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO ProjectControl := do + let dir ← controlDirFor root explicitControlDir? + pure { root, dir, registry := dir / "beam-daemon.json" } + +private def privateControlDirRights : IO.FileRight := { + user := { read := true, write := true, execution := true } +} + +private def privateControlDirMode : UInt32 := + privateControlDirRights.flags + +private def invalidControlDirMessage (dir detail : String) : String := + s!"unsafe Beam control directory {dir}: {detail}. Select a dedicated directory that is a real " ++ + "directory with mode 0700; Beam does not change permissions on existing paths" + +private def permissionModeText (mode : UInt32) : String := + let value := mode.toNat + s!"0{value / 64}{(value / 8) % 8}{value % 8}" + +private inductive ControlDirObservation where + | absent + | privateDir + | symlink + | nonPrivate (mode : UInt32) + | notDirectory + +/-- Inspect the exact control leaf without following a final symbolic link. -/ +private def observeControlDir (dir : System.FilePath) : IO ControlDirObservation := do + try + let metadata ← dir.symlinkMetadata + match metadata.type with + | .dir => + let mode ← Beam.fileModeNoFollow dir + if mode == privateControlDirMode then + pure .privateDir + else + pure <| .nonPrivate mode + | .symlink => pure .symlink + | .file | .other => pure .notDirectory + catch + | .noFileOrDirectory .. => pure .absent + | err => throw err + +private def rejectControlDirObservation + (dir : System.FilePath) : ControlDirObservation → IO Unit + | .symlink => + throw <| IO.userError <| + invalidControlDirMessage dir.toString "symbolic links are not accepted" + | .nonPrivate mode => + throw <| IO.userError <| + invalidControlDirMessage dir.toString + s!"existing mode is {permissionModeText mode}, expected 0700" + | .notDirectory => + throw <| IO.userError <| + invalidControlDirMessage dir.toString "the path is not a directory" + | .absent => + throw <| IO.userError <| + invalidControlDirMessage dir.toString "the path disappeared during control preparation" + | .privateDir => pure () + +/-- Accept an existing control path only when it is a real, account-private directory. -/ +private def validatePrivateControlDir (dir : System.FilePath) : IO Unit := do + rejectControlDirObservation dir (← observeControlDir dir) -def registryPath (root : System.FilePath) : IO System.FilePath := do - Beam.Daemon.registryPath root +/-- +Create a missing dedicated control leaf as private, or validate an existing path without mutating +it. The directory is ready before Beam creates its lock or any capability-bearing descriptor. +-/ +private def preparePrivateControlDir (dir : System.FilePath) : IO Unit := do + match ← observeControlDir dir with + | .privateDir => return + | .absent => pure () + | observation => rejectControlDirObservation dir observation + if let some parent := dir.parent then + IO.FS.createDirAll parent + try + IO.FS.createDir dir + catch + | .alreadyExists .. => + -- A concurrent owner may have created the leaf after our absent observation. Never adopt it + -- implicitly: apply the same read-only validation as any other existing path. + validatePrivateControlDir dir + return + | err => throw err + try + -- This chmod is restricted to the leaf created successfully by this invocation. + IO.setAccessRights dir privateControlDirRights + validatePrivateControlDir dir + catch err => + try + IO.FS.removeDir dir + catch _ => + pure () + throw err + +/-- Supply project registry mutation only for the dynamic extent of the project control lock. -/ +private def withProjectControl + (root : System.FilePath) + (act : ProjectControl → IO α) + (explicitControlDir? : Option System.FilePath := none) : IO α := do + let control ← projectControl root explicitControlDir? + preparePrivateControlDir control.dir + withLockTimeout (control.dir / "lock") (← projectControlLockTimeoutMs) do + act control -private def readRegistry? (root : System.FilePath) : IO (Option RegistryEntry) := - Beam.Daemon.readRegistry? root +/-- +Run teardown under the project lock without recreating a control directory that disappeared with +its project root. +-/ +private def withExistingProjectControl + (root : System.FilePath) + (act : ProjectControl → IO Unit) + (explicitControlDir? : Option System.FilePath := none) : IO Unit := do + let control ← projectControl root explicitControlDir? + unless ← control.dir.isDir do + return + try + withExistingLockTimeout (control.dir / "lock") (← projectControlLockTimeoutMs) do + act control + catch + | .noFileOrDirectory .. => pure () + | err => throw err private def computeConfigHash (root : System.FilePath) @@ -78,65 +195,139 @@ private def computeConfigHash acc := mixField acc bundleId s!"{acc.toNat}" -private def writeRegistry (root : System.FilePath) (entry : RegistryEntry) : IO Unit := do - let path ← registryPath root - if let some parent := path.parent then - IO.FS.createDirAll parent - let tmp := path.withExtension "tmp" - IO.FS.writeFile tmp ((toJson entry).pretty ++ "\n") - IO.FS.rename tmp path +private def hexDigit (n : Nat) : Char := + if n < 10 then + Char.ofNat ('0'.toNat + n) + else + Char.ofNat ('a'.toNat + n - 10) -def removeRegistry (root : System.FilePath) : IO Unit := do - let path ← registryPath root - if ← path.pathExists then - IO.FS.removeFile path +private def byteHex (byte : UInt8) : List Char := + [hexDigit (byte.toNat / 16), hexDigit (byte.toNat % 16)] -def killPid (pid : Nat) : IO Unit := do - try - let _ ← IO.Process.output { cmd := (← killCommand), args := #[toString pid] } - pure () - catch _ => - pure () +private def newRegistryTempPath (control : ProjectControl) : IO System.FilePath := do + let nonce := String.ofList <| (← IO.getRandomBytes 16).toList.flatMap byteHex + pure <| control.dir / s!"beam-daemon-{nonce}.tmp" -partial def waitForPidGone (pid : Nat) (tries : Nat := 20) : IO Unit := do - if tries == 0 then - pure () - else if ← pidAlive pid then - IO.sleep 100 - waitForPidGone pid (tries - 1) - else - pure () - -private def stopDaemonEntry (entry : RegistryEntry) : IO Unit := do - let mayKillPid ← - match registryEndpoint? entry with - | some endpoint => - match ← daemonRoot? endpoint projectDaemonWorkspaceId with - | some daemonRoot => - if ← Beam.sameFilePath (System.FilePath.mk daemonRoot) (System.FilePath.mk entry.root) then - try - let _ ← sendRequest endpoint { op := .shutdown } - pure () - catch _ => - pure () - pure true - else - pure false - | none => - pure true - | none => - pure true - if mayKillPid && entry.pid > 0 && (← pidAlive entry.pid) then - killPid entry.pid - waitForPidGone entry.pid +private def writeRegistry (control : ProjectControl) (entry : SessionDescriptor) : IO Unit := do + let tmp ← newRegistryTempPath control + try + IO.FS.withFile tmp .writeNew fun handle => do + -- The private control directory protects the inode from its creation. The exclusive random + -- path also refuses pre-existing files and symlinks; mode 0600 remains defense in depth and + -- protects the descriptor after publication if directory permissions later change. + IO.setAccessRights tmp { + user := { read := true, write := true } + } + handle.putStr ((toJson entry).pretty ++ "\n") + handle.flush + IO.FS.rename tmp control.registry + catch err => + try + if ← tmp.pathExists then + IO.FS.removeFile tmp + catch _ => + pure () + throw err -def stopRegisteredDaemon (root : System.FilePath) : IO Unit := do - match ← readRegistry? root with - | none => - removeRegistry root - | some entry => - stopDaemonEntry entry - removeRegistry root +private def writeExistingRegistry (control : ProjectControl) (entry : SessionDescriptor) : IO Unit := do + -- Teardown must not create a path while the project tree is being removed. Rewrite through an + -- already existing file handle; if the registry was concurrently unlinked, this updates only the + -- unlinked inode and cannot recreate the project or control directory. + IO.FS.withFile control.registry .readWrite fun handle => do + handle.rewind + handle.putStr ((toJson entry).pretty ++ "\n") + handle.flush + handle.truncate + +private def removeRegistry (control : ProjectControl) : IO Unit := do + if ← control.registry.pathExists then + IO.FS.removeFile control.registry + +private def sameRegistryGeneration (left right : SessionDescriptor) : Bool := + left.daemonId == right.daemonId && left.capability == right.capability + +/-- Remove a registry entry only when it still names the observed daemon generation. -/ +private def removeRegistryGeneration (control : ProjectControl) (entry : SessionDescriptor) : IO Unit := do + match ← readRegistryAt control.registry with + | .current current => + if sameRegistryGeneration current entry then + removeRegistry control + | .absent | .legacy | .unsupported _ | .malformed _ => pure () + +private def daemonShutdownResponseTimeoutMs : Nat := + 30000 + +/-- Ask a daemon to shut down without allowing its response stream to hold CLI control forever. -/ +def requestDaemonShutdown + (endpoint : Transport.Endpoint) + (capability : String) + (responseTimeoutMs : Nat := daemonShutdownResponseTimeoutMs) : + IO (Except BrokerClientFailure Response) := do + sendRequestWithStreamTimeoutResult endpoint { + op := .shutdown + daemonCapability? := some capability + } + responseTimeoutMs (fun _ => pure ()) + +inductive RegistryUnsafeReason where + | invalidIdentity + | wrongRegistryRoot (recordedRoot : String) + | invalidEndpoint + | endpointUnavailable + | endpointUnrecognized (detail : String) + | wrongEndpointRoot (daemonRoot : String) + | wrongGeneration (daemonRoot : String) + deriving BEq, Repr + +inductive RegistryObservation where + | absent + | legacy + | unsupported (schemaVersion : Nat) + | malformed (detail : String) + | live (entry : SessionDescriptor) + | draining (entry : SessionDescriptor) + | unusable (entry : SessionDescriptor) (reason : RegistryUnsafeReason) + +/-- Select the unique descriptor binding for a canonical or filesystem-equivalent project root. -/ +def sessionWorkspaceForRoot? + (entry : SessionDescriptor) + (root : System.FilePath) : IO (Option WorkspaceBinding) := do + for workspace in entry.workspaces do + if ← Beam.sameFilePath (System.FilePath.mk workspace.root) root then + return some workspace + pure none + +private def observeProjectRegistryAt + (root registry : System.FilePath) : IO RegistryObservation := do + match ← readRegistryAt registry with + | .absent => pure .absent + | .legacy => pure .legacy + | .unsupported schemaVersion => pure <| .unsupported schemaVersion + | .malformed detail => pure <| .malformed detail + | .current entry => + if entry.daemonId.isEmpty || entry.capability.isEmpty then + return .unusable entry .invalidIdentity + let some workspace ← sessionWorkspaceForRoot? entry root + | return .unusable entry (.wrongRegistryRoot entry.rootSummary) + if entry.lifecycle == .draining then + return .draining entry + let some endpoint := registryEndpoint? entry + | return .unusable entry .invalidEndpoint + match ← daemonGenerationStatus endpoint workspace.workspaceId root + entry.identity entry.capability with + | .exact => pure <| .live entry + | .unavailable => pure <| .unusable entry .endpointUnavailable + | .unrecognized failure => + pure <| .unusable entry (.endpointUnrecognized failure.detail) + | .wrongRoot daemonRoot => + pure <| .unusable entry (.wrongEndpointRoot daemonRoot) + | .wrongGeneration daemonRoot => + pure <| .unusable entry (.wrongGeneration daemonRoot) + +def observeProjectRegistry + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO RegistryObservation := do + observeProjectRegistryAt root (← registryPathFor root explicitControlDir?) private def requestedPortNat? (opts : CliOptions) : Option Nat := opts.requestedPort?.map (·.toNat) @@ -163,37 +354,37 @@ private partial def selectUnoccupiedEndpoint (opts : CliOptions) (tries : Nat := 10) : IO Transport.Endpoint := do let endpoint ← selectEndpoint opts - match ← daemonRoot? endpoint projectDaemonWorkspaceId with - | none => - pure () - | some daemonRoot => - if usesAutomaticTcpEndpoint opts && tries > 0 then - return ← selectUnoccupiedEndpoint desired opts (tries - 1) - else - throw <| IO.userError (endpointOccupancyError endpoint (System.FilePath.mk daemonRoot) desired.root) - if ← endpointAcceptsConnection endpoint then + let retryOrReject (message : String) : IO Transport.Endpoint := do if usesAutomaticTcpEndpoint opts && tries > 0 then - return ← selectUnoccupiedEndpoint desired opts (tries - 1) + selectUnoccupiedEndpoint desired opts (tries - 1) else - throw <| IO.userError (endpointInUseError endpoint) - else - pure endpoint - -private def daemonStartupLogPath (root : System.FilePath) : IO System.FilePath := do - Beam.Daemon.daemonStartupLogPath root - -private def daemonFailureIncidentDir (root : System.FilePath) : IO System.FilePath := do - Beam.Daemon.daemonFailureIncidentDir root + throw <| IO.userError message + match ← daemonRootResult endpoint projectDaemonWorkspaceId with + | .ok daemonRoot => + retryOrReject <| endpointOccupancyError endpoint + (System.FilePath.mk daemonRoot) desired.root + | .error failure => + let occupied ← + match failure with + | .transport _ _ => endpointAcceptsConnection endpoint + | .invalidResponse _ | .streamCallback _ | .responseTimeout _ => pure true + if !occupied then + pure endpoint + else + let message := + match failure with + | .transport _ _ => endpointInUseError endpoint + | .invalidResponse _ | .streamCallback _ | .responseTimeout _ => + endpointProtocolError endpoint failure.detail + retryOrReject message private def daemonFailureIncidentRetainCount : Nat := 50 -def recentDaemonFailureIncidentPaths (root : System.FilePath) (limit : Nat := 5) : - IO (Array System.FilePath) := - Beam.Daemon.recentDaemonFailureIncidentPaths root limit - -private def pruneDaemonFailureIncidents (root : System.FilePath) : IO Unit := do - let entries ← Beam.Daemon.daemonFailureIncidentEntries root +private def pruneDaemonFailureIncidents + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO Unit := do + let entries ← Beam.Daemon.daemonFailureIncidentEntries root explicitControlDir? let keep := min daemonFailureIncidentRetainCount entries.size let deleteCount := entries.size - keep for entry in entries.toList.take deleteCount do @@ -206,12 +397,6 @@ private def appendMaybeSection (msg : String) : Option String → String | none => msg | some context => msg ++ "\n" ++ context -private def registryEndpointSummary (entry : RegistryEntry) : String := - Beam.Daemon.registryEndpointSummary entry - -private def registryPidStatus (entry : RegistryEntry) : IO String := - Beam.Daemon.registryPidStatus entry - private structure DaemonFailureIncident where schemaVersion : Nat kind : String @@ -220,8 +405,7 @@ private structure DaemonFailureIncident where root : String controlDir : String registryPath : String - registry : Option RegistryEntry := none - registryPidStatus : Option String := none + registry : Option Json := none registryEndpoint : Option String := none startupLogPath : Option String := none startupLogTail : Option String := none @@ -230,24 +414,20 @@ private structure DaemonFailureIncident where private def daemonFailureIncidentSchemaVersion : Nat := 1 -private def daemonFailureKind? (detail : String) : Option String := - if detail.contains "Beam daemon connection closed" then - some "connectionClosed" - else if detail.contains "no live Beam daemon registered for " then - some "noLiveDaemon" - else - none - -private def startupLogTail? (root : System.FilePath) : IO (Option (System.FilePath × String)) := - Beam.Daemon.startupLogTail? root +private def daemonFailureIncidentKind? : BrokerClientFailure → Option String + | .transport _ _ => some "brokerTransportFailure" + | .invalidResponse _ => some "invalidBrokerResponse" + | .streamCallback _ => none + | .responseTimeout _ => some "brokerResponseTimeout" private def daemonFailureIncidentTimestampLabel (timestamp : String) : String := (timestamp.replace "-" "").replace ":" "" private def daemonFailureIncidentPath (root : System.FilePath) - (kind observedAt : String) : IO System.FilePath := do - let dir ← daemonFailureIncidentDir root + (kind observedAt : String) + (explicitControlDir? : Option System.FilePath := none) : IO System.FilePath := do + let dir ← daemonFailureIncidentDirFor root explicitControlDir? let pid ← IO.Process.getPID let unique ← IO.monoNanosNow let stamp := daemonFailureIncidentTimestampLabel observedAt @@ -256,19 +436,17 @@ private def daemonFailureIncidentPath private def writeDaemonFailureIncident? (root : System.FilePath) (kind detail : String) - (logTail? : Option (System.FilePath × String)) : IO (Option System.FilePath) := do + (logTail? : Option (System.FilePath × String)) + (explicitControlDir? : Option System.FilePath := none) : IO (Option System.FilePath) := do try - let dir ← daemonFailureIncidentDir root + let dir ← daemonFailureIncidentDirFor root explicitControlDir? IO.FS.createDirAll dir - let registryFile ← registryPath root - let registry ← readRegistry? root - let pidStatus ← - match registry with - | none => pure none - | some entry => some <$> registryPidStatus entry + let registryFile ← registryPathFor root explicitControlDir? + let registryRead ← readRegistryAt registryFile + let registry := registryRead.entry? let endpoint := registry.map registryEndpointSummary - let control ← controlDir root - let observedAt ← utcTimestamp + let control ← controlDirFor root explicitControlDir? + let observedAt ← Beam.utcTimestamp let incident : DaemonFailureIncident := { schemaVersion := daemonFailureIncidentSchemaVersion kind @@ -277,65 +455,103 @@ private def writeDaemonFailureIncident? root := root.toString controlDir := control.toString registryPath := registryFile.toString - registry - registryPidStatus := pidStatus + registry := registry.map fun entry => + entry.redactedJson registryEndpoint := endpoint startupLogPath := logTail?.map (fun (path, _) => path.toString) startupLogTail := logTail?.map (fun (_, tail) => tail) } - let path ← daemonFailureIncidentPath root kind observedAt + let path ← daemonFailureIncidentPath root kind observedAt explicitControlDir? let tmp := path.withExtension "tmp" IO.FS.writeFile tmp ((toJson incident).pretty ++ "\n") IO.FS.rename tmp path try - pruneDaemonFailureIncidents root + pruneDaemonFailureIncidents root explicitControlDir? catch _ => pure () pure (some path) catch _ => pure none -private def daemonRegistryContext? (root : System.FilePath) : IO (Option String) := - Beam.Daemon.daemonRegistryContext? root - -def daemonDebugContextJson (root : System.FilePath) : IO Json := - Beam.Daemon.daemonDebugContextJson root - -def daemonFailureMessage (root : System.FilePath) (detail : String) : IO String := do - match daemonFailureKind? detail with +def daemonFailureMessage + (root : System.FilePath) + (failure : BrokerClientFailure) + (explicitControlDir? : Option System.FilePath := none) : IO String := do + let detail := failure.detail + match daemonFailureIncidentKind? failure with | none => pure detail | some kind => - let msg := appendMaybeSection detail (← daemonRegistryContext? root) - let logTail? ← startupLogTail? root + let msg := appendMaybeSection detail (← daemonRegistryContext? root explicitControlDir?) + let logTail? ← startupLogTail? root explicitControlDir? let msg := match logTail? with | none => msg | some (logPath, logTail) => msg ++ s!"\nBeam daemon log tail ({logPath}):\n{logTail}" - let incidentPath? ← writeDaemonFailureIncident? root kind detail logTail? + let incidentPath? ← writeDaemonFailureIncident? root kind detail logTail? explicitControlDir? pure <| appendMaybeSection msg <| incidentPath?.map fun path => s!"Beam daemon incident: {path}" -private def startupFailureMessage (endpoint : Transport.Endpoint) (logPath : System.FilePath) (detail : String) : - IO String := do +private structure DaemonStartupFailure where + message : String + endpointInUse : Bool := false + +private def daemonStartupFailure + (endpoint : Transport.Endpoint) + (logPath : System.FilePath) + (detail : String) : IO DaemonStartupFailure := do let msg := if detail.isEmpty then s!"failed to start Beam daemon on {endpointSummary endpoint}" else s!"failed to start Beam daemon on {endpointSummary endpoint}\n{detail}" if ← logPath.pathExists then - let logText := trimLine (← IO.FS.readFile logPath) + let logText := Beam.trimLine (← IO.FS.readFile logPath) if logText.isEmpty then - pure msg + pure { message := msg } else - pure <| msg ++ s!"\nstartup log ({logPath}):\n{logText}" + pure { + message := msg ++ s!"\nstartup log ({logPath}):\n{logText}" + endpointInUse := startupLogSuggestsEndpointInUse logText + } else - pure msg + pure { message := msg } + +private abbrev daemonStdio : IO.Process.StdioConfig where + stdin := .piped + stdout := .null + stderr := .null + +private partial def waitForDaemonChildExit + {cfg : IO.Process.StdioConfig} + (child : IO.Process.Child cfg) + (tries : Nat := 20) : IO Unit := do + if tries == 0 || (← child.tryWait).isSome then + return + IO.sleep 100 + waitForDaemonChildExit child (tries - 1) + +private def terminateDaemonChild + {cfg : IO.Process.StdioConfig} + (child : IO.Process.Child cfg) : IO Unit := do + try + if (← child.tryWait).isNone then + child.kill + waitForDaemonChildExit child + catch _ => + pure () -private def startDaemon (desired : DesiredConfig) (endpoint : Transport.Endpoint) (logPath : System.FilePath) : - IO Nat := do +private def startDaemon + (desired : DesiredConfig) + (endpoint : Transport.Endpoint) + (logPath : System.FilePath) + (identity : DaemonIdentity) + (capability : String) : IO (IO.Process.Child daemonStdio) := do let mut args : List String := [ "--root", desired.root.toString, - "--workspace-id", projectDaemonWorkspaceId + "--workspace-id", projectDaemonWorkspaceId, + "--daemon-id", identity.daemonId, + "--config-hash", identity.configHash, + "--session-owner-stdin" ] match endpoint with | .tcp port => @@ -350,82 +566,154 @@ private def startDaemon (desired : DesiredConfig) (endpoint : Transport.Endpoint IO.FS.createDirAll parent IO.FS.writeFile logPath "" let cmd := String.intercalate " " ((desired.daemonBin.toString :: args).map shellQuote) - let shell := s!"exec {cmd} >{shellQuote logPath.toString} 2>&1 < /dev/null" + let shell := s!"exec {cmd} >{shellQuote logPath.toString} 2>&1" let child ← IO.Process.spawn { + toStdioConfig := daemonStdio cmd := "sh" args := #["-c", shell] cwd := some desired.root - stdin := .null - stdout := .null - stderr := .null + setsid := true } - let pid := child.pid.toNat - pure pid + try + child.stdin.putStrLn capability + child.stdin.flush + pure child + catch err => + -- Once spawned, the retained child handle owns the whole setsid process group. Do not leak + -- that acquisition when publishing the capability through the owner pipe fails. + terminateDaemonChild child + throw err -private partial def waitForDaemon - (pid : Nat) +private def daemonStartupTimeoutMs : Nat := + 30000 + +private partial def waitForDaemonUntil + (child : IO.Process.Child daemonStdio) (endpoint : Transport.Endpoint) (logPath : System.FilePath) (root : System.FilePath) - (tries : Nat := 300) : IO Unit := do - match ← daemonRoot? endpoint projectDaemonWorkspaceId with - | some daemonRoot => - if ← Beam.sameFilePath (System.FilePath.mk daemonRoot) root then - pure () - else - throw <| IO.userError (endpointOccupancyError endpoint (System.FilePath.mk daemonRoot) root) - | none => - if !(← pidAlive pid) then - throw <| IO.userError (← startupFailureMessage endpoint logPath "Beam daemon process exited before responding") - else if tries == 0 then - throw <| IO.userError (← startupFailureMessage endpoint logPath "Beam daemon did not become ready before timeout") - else - IO.sleep 100 - waitForDaemon pid endpoint logPath root (tries - 1) - -private def registryEntryFor (desired : DesiredConfig) (pid : Nat) (endpoint : Transport.Endpoint) (opts : CliOptions) : - IO RegistryEntry := do + (identity : DaemonIdentity) + (capability : String) + (deadlineNanos : Nat) + (timeoutDetail : String) : IO (Except DaemonStartupFailure Unit) := do + if (← child.tryWait).isSome then + return .error (← daemonStartupFailure endpoint logPath + "Beam daemon process exited before responding") + if (← IO.monoNanosNow) >= deadlineNanos then + return .error (← daemonStartupFailure endpoint logPath timeoutDetail) + let retryOrFail (detail : String) : IO (Except DaemonStartupFailure Unit) := do + if (← child.tryWait).isSome then + .error <$> daemonStartupFailure endpoint logPath "Beam daemon process exited before responding" + else if (← IO.monoNanosNow) >= deadlineNanos then + .error <$> daemonStartupFailure endpoint logPath detail + else + IO.sleep 100 + waitForDaemonUntil child endpoint logPath root identity capability deadlineNanos detail + match ← daemonGenerationStatus endpoint projectDaemonWorkspaceId root identity capability with + | .exact => pure (.ok ()) + | .wrongRoot daemonRoot => + pure <| .error { + message := endpointOccupancyError endpoint (System.FilePath.mk daemonRoot) root + endpointInUse := true + } + | .wrongGeneration daemonRoot => + pure <| .error { + message := endpointGenerationMismatchError endpoint (System.FilePath.mk daemonRoot) + endpointInUse := true + } + | .unrecognized failure => + retryOrFail (endpointProtocolError endpoint failure.detail) + | .unavailable => + retryOrFail "Beam daemon did not become ready before timeout" + +private def waitForDaemon + (child : IO.Process.Child daemonStdio) + (endpoint : Transport.Endpoint) + (logPath : System.FilePath) + (root : System.FilePath) + (identity : DaemonIdentity) + (capability : String) : IO (Except DaemonStartupFailure Unit) := do + let deadlineNanos := (← IO.monoNanosNow) + daemonStartupTimeoutMs * 1000000 + waitForDaemonUntil child endpoint logPath root identity capability deadlineNanos + "Beam daemon did not become ready before timeout" + +private def newDaemonGenerationId (configHash : String) : IO String := do + let startedMonoNanos ← IO.monoNanosNow + let nonce := ByteArray.toUInt64LE! (← IO.getRandomBytes 8) + pure s!"{configHash.take 12}-{startedMonoNanos}-{nonce}" + +private def newDaemonCapability : IO String := do + let bytes ← IO.getRandomBytes 32 + pure <| String.ofList <| bytes.toList.flatMap byteHex + +private def registryEntryFor + (desired : DesiredConfig) + (daemonId : String) + (capability : String) + (pid : Nat) + (endpoint : Transport.Endpoint) + (opts : CliOptions) : IO SessionDescriptor := do let port? := match endpoint with | .tcp port => some port.toNat + let ownerPid ← IO.Process.getPID pure { - daemonId := s!"{desired.configHash.take 12}-{pid}" + schemaVersion := registrySchemaVersion + lifecycle := .live + daemonId + capability pid - pidNamespace? := ← currentPidNamespace? + ownerPid := ownerPid.toNat port? - root := desired.root.toString + workspaces := #[{ + workspaceId := projectDaemonWorkspaceId + root := desired.root.toString + configHash := desired.configHash + leanCmd? := desired.leanCmd? + plugin? := desired.plugin?.map (·.toString) + rocqCmd? := desired.rocqCmd? + toolchain? := desired.toolchain? + bundleId? := some desired.bundleId + }] configHash := desired.configHash - leanCmd? := desired.leanCmd? - plugin? := desired.plugin?.map (·.toString) - rocqCmd? := desired.rocqCmd? - toolchain? := desired.toolchain? clientBin? := some desired.clientBin.toString daemonBin? := some desired.daemonBin.toString - bundleId? := some desired.bundleId - startedAt := ← utcTimestamp + startedAt := ← Beam.utcTimestamp requestedPort? := requestedPortNat? opts } private partial def startDaemonEntry (desired : DesiredConfig) (opts : CliOptions) - (tries : Nat := 10) : IO (Transport.Endpoint × RegistryEntry) := do + (controlDir : System.FilePath) + (tries : Nat := 10) : IO (Transport.Endpoint × SessionDescriptor × IO.Process.Child daemonStdio) := do let endpoint ← selectUnoccupiedEndpoint desired opts - let logPath ← daemonStartupLogPath desired.root - let pid ← startDaemon desired endpoint logPath - try - waitForDaemon pid endpoint logPath desired.root - catch err => - if pid > 0 && (← pidAlive pid) then - killPid pid - waitForPidGone pid + let logPath ← daemonStartupLogPathFor desired.root (some controlDir) + let daemonId ← newDaemonGenerationId desired.configHash + let identity : DaemonIdentity := { daemonId, configHash := desired.configHash } + let capability ← newDaemonCapability + let child ← startDaemon desired endpoint logPath identity capability + let readiness : Except DaemonStartupFailure SessionDescriptor ← + try + match ← waitForDaemon child endpoint logPath desired.root identity capability with + | .ok () => + let entry ← registryEntryFor desired daemonId capability child.pid.toNat endpoint opts + pure (.ok entry) + | .error failure => + pure (.error failure) + catch err => + terminateDaemonChild child + throw err + match readiness with + | .ok entry => + pure (endpoint, entry, child) + | .error failure => + terminateDaemonChild child let endpointOccupied ← endpointAcceptsConnection endpoint - let startupAddressInUse := startupFailureSuggestsEndpointInUse (toString err) - if shouldRetryAutomaticStartup (usesAutomaticTcpEndpoint opts) tries endpointOccupied startupAddressInUse then - return ← startDaemonEntry desired opts (tries - 1) - throw err - let entry ← registryEntryFor desired pid endpoint opts - pure (endpoint, entry) + if shouldRetryAutomaticStartup + (usesAutomaticTcpEndpoint opts) tries endpointOccupied failure.endpointInUse then + return ← startDaemonEntry desired opts controlDir (tries - 1) + throw <| IO.userError failure.message def desiredConfig (home root : System.FilePath) (required : Backend) : IO DesiredConfig := do let defaultPaths ← defaultBundlePaths home @@ -478,160 +766,438 @@ def desiredConfig (home root : System.FilePath) (required : Backend) : IO Desire configHash } -def registryLiveFor (root : System.FilePath) (expectedHash? : Option String := none) : IO (Option RegistryEntry) := do - match ← readRegistry? root with - | none => pure none - | some entry => - let rootOk ← Beam.sameFilePath (System.FilePath.mk entry.root) root - let hashOk := expectedHash?.map (· == entry.configHash) |>.getD true - if !rootOk || !hashOk then - pure none - else if let some endpoint := registryEndpoint? entry then - -- In PID-isolated sandboxes, the recorded daemon pid can be meaningless outside - -- the namespace that started it. Prefer a root-matching endpoint over pid probes. - if ← daemonServesRoot endpoint projectDaemonWorkspaceId root then - pure (some entry) - else if entry.pid == 0 || !(← pidAlive entry.pid) then - pure none - else - pure none - else if entry.pid == 0 || !(← pidAlive entry.pid) then - pure none - else - pure none - -structure EnsuredProjectDaemon where +structure ProjectDaemonClient where endpoint : Transport.Endpoint - startedNew : Bool := false + capability : String + workspaceId : WorkspaceId + controlDir : System.FilePath + +def ProjectDaemonClient.authorize + (client : ProjectDaemonClient) + (request : Request) : Request := + { request with daemonCapability? := some client.capability } + +private def projectDaemonClient + (entry : SessionDescriptor) + (workspace : WorkspaceBinding) + (controlDir : System.FilePath) : IO ProjectDaemonClient := do + pure { + endpoint := ← Beam.Daemon.endpointFromEntry entry + capability := entry.capability + workspaceId := workspace.workspaceId + controlDir + } -def ensureProjectDaemon (home root : System.FilePath) (backend : Backend) (opts : CliOptions) : - IO EnsuredProjectDaemon := do - let desired ← desiredConfig home root backend - withProjectControlLock root do - if let some live ← registryLiveFor root desired.configHash then - if let some endpoint := registryEndpoint? live then - return { endpoint, startedNew := false } - removeRegistry root - let live? ← registryLiveFor root - if live?.isNone then - removeRegistry root - let (endpoint, entry) ← startDaemonEntry desired opts - writeRegistry root entry - if let some live := live? then - unless live.pid == entry.pid && - live.port? == entry.port? do - stopDaemonEntry live - pure { endpoint, startedNew := true } - -private structure WrapperLease where - root : System.FilePath - path : System.FilePath +private def workspaceSupportsBackend (workspace : WorkspaceBinding) : Backend → Bool + | .lean => workspace.leanCmd?.isSome && workspace.plugin?.isSome + | .rocq => workspace.rocqCmd?.isSome -private structure WrapperLeaseMetadata where - pid : Nat - pidNamespace? : Option String := none - createdAt : String - deriving FromJson, ToJson +private def selectWorkspaceBackend + (root : System.FilePath) + (entry : SessionDescriptor) + (backend? : Option Backend) : IO WorkspaceBinding := do + let some workspace ← sessionWorkspaceForRoot? entry root + | throw <| IO.userError s!"the selected Beam session does not contain workspace {root}" + if let some backend := backend? then + unless workspaceSupportsBackend workspace backend do + throw <| IO.userError <| + s!"the owned Beam session for {root} does not provide the {toJson backend |>.compress} backend; " ++ + "interrupt its foreground owner and start a session configured for that backend" + pure workspace + +structure SelectedProjectDaemon where + client : ProjectDaemonClient + workspace : WorkspaceBinding + +def RegistryUnsafeReason.message : RegistryUnsafeReason → String + | .invalidIdentity => "registry identity or capability is empty" + | .wrongRegistryRoot recordedRoot => s!"registry records another root: {recordedRoot}" + | .invalidEndpoint => "registry endpoint is invalid" + | .endpointUnavailable => "the recorded daemon endpoint is unavailable" + | .endpointUnrecognized detail => s!"the recorded endpoint is not a recognized Beam generation: {detail}" + | .wrongEndpointRoot daemonRoot => s!"the recorded endpoint serves another root: {daemonRoot}" + | .wrongGeneration daemonRoot => + s!"the recorded endpoint serves another Beam generation for {daemonRoot}" + +private def activeOwnerMessage (root : System.FilePath) (entry : SessionDescriptor) : String := + s!"Beam session for {root} is already owned by wrapper pid {entry.ownerPid}; " ++ + "interrupt that 'lean-beam ensure --hold' process before starting another owner" + +private def configMismatchMessage + (root : System.FilePath) + (entry : SessionDescriptor) + (expectedHash : String) : String := + s!"the live Beam session for {root} uses configuration {entry.configHash}, " ++ + s!"but this command requires {expectedHash}; the current owner was preserved. " ++ + "Interrupt its 'lean-beam ensure --hold' process, then start a new owner with the desired configuration" + +private def drainingOwnerMessage (root : System.FilePath) (entry : SessionDescriptor) : String := + s!"Beam session {entry.daemonId} for {root} is draining; " ++ + "wait for its foreground owner to exit before starting or attaching to another session" + +private def registryRecoveryMessage (root : System.FilePath) (detail : String) : String := + s!"Beam cannot safely use or replace the daemon registry for {root}: {detail}. " ++ + "The session remains fenced; preserve its descriptor and use explicit recovery with the same " ++ + "--root and --control-dir selection after stopping the matching owner or daemon" + +private def generationRecoveryMessage + (root : System.FilePath) + (entry : SessionDescriptor) + (reason : RegistryUnsafeReason) : String := + let message := registryRecoveryMessage root reason.message + match reason with + | .wrongRegistryRoot recordedRoots => + message ++ "; recovery must select one of the descriptor's recorded workspace roots " ++ + s!"({recordedRoots}) with the same --control-dir; the selected root {root} cannot recover " ++ + s!"session {entry.daemonId}" + | _ => + message ++ "; run " ++ + s!"'lean-beam --root {root} recover --generation {entry.daemonId}' when recovery is safe" + +private def registryReadRecoveryMessage + (root : System.FilePath) + (registryRead : RegistryRead) : String := + registryRecoveryMessage root + (registryRead.detail?.getD s!"unexpected registry state '{registryRead.status}'") ++ + "; opaque state can be quarantined explicitly with " ++ + "'lean-beam --root ROOT recover --force'" + +private def markRegistryDraining (control : ProjectControl) (entry : SessionDescriptor) : IO Unit := do + match ← readRegistryAt control.registry with + | .current current => + if sameRegistryGeneration current entry && current.lifecycle == .live then + writeExistingRegistry control { current with lifecycle := .draining } + | .absent | .legacy | .unsupported _ | .malformed _ => pure () + +private inductive ShutdownPlan where + | none + | request (entry : SessionDescriptor) + +/-- Fence and request shutdown of the exact wrapper-owned generation without PID signalling. -/ +def shutdownRegisteredProjectDaemon + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : + IO (Except BrokerClientFailure (Option Response)) := do + let plan : ShutdownPlan ← withProjectControl root + (explicitControlDir? := explicitControlDir?) fun control => do + match ← observeProjectRegistryAt root control.registry with + | .absent => pure ShutdownPlan.none + | .live entry => + markRegistryDraining control entry + pure <| ShutdownPlan.request entry + | .draining entry => pure <| ShutdownPlan.request entry + | .legacy => + throw <| IO.userError <| registryReadRecoveryMessage root .legacy + | .unsupported schemaVersion => + throw <| IO.userError <| registryReadRecoveryMessage root (.unsupported schemaVersion) + | .malformed detail => + throw <| IO.userError <| registryReadRecoveryMessage root (.malformed detail) + | .unusable entry reason => + throw <| IO.userError <| generationRecoveryMessage root entry reason + match plan with + | ShutdownPlan.none => pure <| .ok none + | ShutdownPlan.request entry => + let some endpoint := registryEndpoint? entry + | return .error <| .invalidResponse "draining Beam registry has no valid endpoint" + pure <| (← requestDaemonShutdown endpoint entry.capability).map some + +structure RecoveryResult where + recovered : Bool + generation? : Option String := none + quarantinedPath? : Option String := none + reason? : Option String := none + deriving ToJson -private def wrapperLeaseDir (root : System.FilePath) : IO System.FilePath := do - pure ((← controlDir root) / "wrapper-leases") +private def quarantineRegistry (control : ProjectControl) : IO System.FilePath := do + let nonce ← IO.monoNanosNow + let quarantine := control.dir / s!"beam-daemon.recovered-{nonce}.json" + IO.FS.rename control.registry quarantine + pure quarantine -private def removeWrapperLeasePath (path : System.FilePath) : IO Unit := do - try - if ← path.pathExists then - IO.FS.removeFile path - catch _ => - pure () +private def registeredGenerationResponds + (root : System.FilePath) + (workspace : WorkspaceBinding) + (entry : SessionDescriptor) : IO Bool := do + let some endpoint := registryEndpoint? entry + | pure false + match ← daemonGenerationStatus endpoint workspace.workspaceId root entry.identity entry.capability with + | .exact => pure true + | .unavailable | .unrecognized _ | .wrongRoot _ | .wrongGeneration _ => pure false -private def acquireWrapperLease (root : System.FilePath) : IO WrapperLease := do - let dir ← wrapperLeaseDir root - IO.FS.createDirAll dir - let pid ← IO.Process.getPID - let stamp ← IO.monoNanosNow - let path := dir / s!"{stamp}-{pid}.lease" - let tmp := dir / s!"{stamp}-{pid}.lease.tmp" - let metadata : WrapperLeaseMetadata := { - pid := pid.toNat - pidNamespace? := ← currentPidNamespace? - createdAt := ← utcTimestamp +/-- +Explicitly quarantine one unusable session descriptor without treating persisted PIDs as signal +capabilities. Current descriptors require their exact generation; opaque descriptors require force. +-/ +def recoverProjectDaemon + (root : System.FilePath) + (generation? : Option String) + (forceOpaque : Bool) + (explicitControlDir? : Option System.FilePath := none) : IO RecoveryResult := do + withProjectControl root (explicitControlDir? := explicitControlDir?) fun control => do + match ← readRegistryAt control.registry with + | .absent => + pure { recovered := false, reason? := some "absent" } + | .current entry => + let some generation := generation? + | throw <| IO.userError + s!"recovery of current session {entry.daemonId} requires --generation {entry.daemonId}" + unless generation == entry.daemonId do + throw <| IO.userError <| + s!"recovery generation '{generation}' does not match recorded generation '{entry.daemonId}'" + let some workspace ← sessionWorkspaceForRoot? entry root + | throw <| IO.userError <| + s!"selected root {root} is not a workspace in session {entry.daemonId}; " ++ + s!"recorded workspace roots: {entry.rootSummary}" + if ← registeredGenerationResponds root workspace entry then + throw <| IO.userError <| + s!"Beam session {entry.daemonId} still responds; stop its foreground owner or use authenticated shutdown" + let quarantine ← quarantineRegistry control + pure { + recovered := true + generation? := some entry.daemonId + quarantinedPath? := some quarantine.toString + } + | .legacy | .unsupported _ | .malformed _ => + unless forceOpaque do + throw <| IO.userError + "opaque legacy, unsupported, or malformed session state requires recover --force" + let quarantine ← quarantineRegistry control + pure { + recovered := true + quarantinedPath? := some quarantine.toString + reason? := some "opaque" + } + +private abbrev detachedDaemonStdio : IO.Process.StdioConfig where + stdin := .null + stdout := .null + stderr := .null + +private structure OwnedProjectDaemon where + client : ProjectDaemonClient + entry : SessionDescriptor + child : IO.Process.Child daemonStdio + +structure ProjectDaemonOwner where + client : ProjectDaemonClient + private root : System.FilePath + private controlDir : System.FilePath + private daemonId : String + private child : IO.Process.Child daemonStdio + private exitCodeRef : IO.Ref (Option UInt32) + +def ProjectDaemonOwner.exitCode? (owner : ProjectDaemonOwner) : IO (Option UInt32) := do + match ← owner.exitCodeRef.get with + | some exitCode => pure (some exitCode) + | none => + let exitCode? ← owner.child.tryWait + if let some exitCode := exitCode? then + owner.exitCodeRef.set (some exitCode) + pure exitCode? + +/-- Whether this owner generation is still the one published for its project. -/ +def ProjectDaemonOwner.registered (owner : ProjectDaemonOwner) : IO Bool := do + match ← readRegistryAt (owner.controlDir / "beam-daemon.json") with + | .current current => + pure (current.daemonId == owner.daemonId && + current.capability == owner.client.capability && current.lifecycle == .live) + | .absent | .legacy | .unsupported _ | .malformed _ => pure false + +private def missingOwnerCommand : Option Backend → String + | some .rocq => "lean-beam ensure rocq --hold" + | some .lean | none => "lean-beam ensure --hold" + +private def missingOwnerMessage (root : System.FilePath) (backend? : Option Backend) : String := + s!"no live Beam session owner is registered for {root}; " ++ + s!"start '{missingOwnerCommand backend?}' for this project and keep it running while using wrapper commands" + +private def startOwnedProjectDaemon + (control : ProjectControl) + (desired : DesiredConfig) + (opts : CliOptions) : IO OwnedProjectDaemon := do + match ← observeProjectRegistryAt desired.root control.registry with + | .absent => pure () + | .live entry => + if entry.configHash == desired.configHash then + throw <| IO.userError (activeOwnerMessage desired.root entry) + else + throw <| IO.userError (configMismatchMessage desired.root entry desired.configHash) + | .draining entry => throw <| IO.userError (drainingOwnerMessage desired.root entry) + | .legacy => + throw <| IO.userError <| registryReadRecoveryMessage desired.root .legacy + | .unsupported schemaVersion => + throw <| IO.userError <| + registryReadRecoveryMessage desired.root (.unsupported schemaVersion) + | .malformed detail => + throw <| IO.userError <| registryReadRecoveryMessage desired.root (.malformed detail) + | .unusable entry reason => + throw <| IO.userError <| generationRecoveryMessage desired.root entry reason + let (endpoint, entry, child) ← startDaemonEntry desired opts control.dir + try + writeRegistry control entry + catch err => + terminateDaemonChild child + throw err + pure { + client := { + endpoint + capability := entry.capability + workspaceId := projectDaemonWorkspaceId + controlDir := control.dir + } + entry + child } - IO.FS.writeFile tmp ((toJson metadata).pretty ++ "\n") - IO.FS.rename tmp path - pure { root, path } -private def releaseWrapperLease (lease : WrapperLease) : IO Unit := do - removeWrapperLeasePath lease.path +private def closeDaemonOwnerPipe + (child : IO.Process.Child daemonStdio) : + IO (IO.Process.Child detachedDaemonStdio) := do + let (_ownerPipe, child) ← child.takeStdin + pure child + +private partial def waitForOwnedDaemonExit + {cfg : IO.Process.StdioConfig} + (child : IO.Process.Child cfg) + (exitCodeRef : IO.Ref (Option UInt32)) + (tries : Nat) : IO Unit := do + if (← exitCodeRef.get).isSome || tries == 0 then + return + if let some exitCode ← child.tryWait then + exitCodeRef.set (some exitCode) + else + IO.sleep 100 + waitForOwnedDaemonExit child exitCodeRef (tries - 1) -private def readWrapperLeaseMetadata? (path : System.FilePath) : IO (Option WrapperLeaseMetadata) := do +private def removeOwnedRegistry + (root controlDir : System.FilePath) + (entry : SessionDescriptor) : IO Unit := do try - let text ← IO.FS.readFile path - let json ← IO.ofExcept <| Json.parse text - let metadata ← IO.ofExcept <| fromJson? json - pure (some metadata) + withExistingProjectControl root (explicitControlDir? := some controlDir) fun control => + removeRegistryGeneration control entry catch _ => - pure none - -private def staleWrapperLease? (currentNamespace? : Option String) (path : System.FilePath) : - IO Bool := do - match ← readWrapperLeaseMetadata? path with - | none => pure false - | some metadata => - if metadata.pid == 0 then - pure true - else if metadata.pidNamespace? == currentNamespace? then - pure (!(← pidAlive metadata.pid)) - else - -- The PID may be meaningful only inside a different sandbox namespace. - pure false - -private def activeOtherWrapperLeases (lease : WrapperLease) : IO (Array IO.FS.DirEntry) := do - let dir ← wrapperLeaseDir lease.root - unless ← dir.pathExists do - return #[] - let currentNamespace? ← currentPidNamespace? - let entries ← dir.readDir - let mut active := #[] - for entry in entries do - if entry.path != lease.path && entry.fileName.endsWith ".lease" then - if ← staleWrapperLease? currentNamespace? entry.path then - removeWrapperLeasePath entry.path - else - active := active.push entry - pure active + pure () -private partial def waitForOtherWrapperLeases (lease : WrapperLease) (tries : Nat := 600) : IO Unit := do - let others ← activeOtherWrapperLeases lease - if others.isEmpty then +private def attemptCleanup (act : IO Unit) : IO Unit := do + try + act + catch _ => pure () - else if tries == 0 then + +private def finishOwnedDaemonChild + (owned : OwnedProjectDaemon) + (exitCodeRef : IO.Ref (Option UInt32)) : IO Bool := do + try + let child ← closeDaemonOwnerPipe owned.child + attemptCleanup <| waitForOwnedDaemonExit child exitCodeRef 100 + if (← exitCodeRef.get).isNone then + -- `startDaemon` uses `setsid`; Lean's retained child handle therefore kills the complete + -- daemon process group rather than only the broker PID. + attemptCleanup child.kill + attemptCleanup <| waitForOwnedDaemonExit child exitCodeRef 20 + catch _ => + attemptCleanup owned.child.kill + attemptCleanup <| waitForOwnedDaemonExit owned.child exitCodeRef 20 + pure (← exitCodeRef.get).isSome + +private def markOwnedRegistryDraining + (root controlDir : System.FilePath) + (entry : SessionDescriptor) : IO Unit := do + try + withExistingProjectControl root (explicitControlDir? := some controlDir) fun control => + markRegistryDraining control entry + catch _ => pure () - else - IO.sleep 50 - waitForOtherWrapperLeases lease (tries - 1) -def withWrapperLease (root : System.FilePath) (startedNew : Bool) (act : IO α) : IO α := do - let lease ← acquireWrapperLease root - let result ← - try - pure <| Except.ok (← act) - catch err => - pure <| Except.error err - if startedNew then - -- The wrapper invocation that started the daemon must not exit early while sibling - -- wrapper requests for the same root are still in flight, or its sandbox can kill the daemon. - waitForOtherWrapperLeases lease - releaseWrapperLease lease - match result with - | .ok value => pure value - | .error err => throw err - -def lookupProjectDaemon (root : System.FilePath) : IO RegistryEntry := do - withProjectControlLock root do - match ← registryLiveFor root with - | some entry => pure entry +private def finishOwnedProjectDaemon + (root : System.FilePath) + (controlDir : System.FilePath) + (owned : OwnedProjectDaemon) + (exitCodeRef : IO.Ref (Option UInt32)) : IO Unit := do + let exitedBeforeOwnerCleanup ← + match ← exitCodeRef.get with + | some _ => pure true | none => - let msg ← daemonFailureMessage root s!"no live Beam daemon registered for {root}" - stopRegisteredDaemon root - throw <| IO.userError msg + match ← owned.child.tryWait with + | some exitCode => + exitCodeRef.set (some exitCode) + pure true + | none => pure false + let registryWasDraining ← + match ← readRegistryAt (controlDir / "beam-daemon.json") with + | .current current => + pure (sameRegistryGeneration current owned.entry && current.lifecycle == .draining) + | .absent | .legacy | .unsupported _ | .malformed _ => pure false + markOwnedRegistryDraining root controlDir owned.entry + if exitedBeforeOwnerCleanup && !registryWasDraining then + -- An unexpected daemon exit is not evidence that its complete process tree disappeared. Keep + -- the exact generation fenced for explicit, non-signalling recovery. + pure () + else if ← finishOwnedDaemonChild owned exitCodeRef then + removeOwnedRegistry root controlDir owned.entry +def withProjectDaemonOwner + (home root : System.FilePath) + (backend : Backend) + (opts : CliOptions) + (act : ProjectDaemonOwner → IO α) : IO α := do + let controlDir ← controlDirFor root opts.explicitControlDir? + -- Establish or validate the control boundary before bundle resolution can create project-local + -- `.beam` state for a previously unseen toolchain. + preparePrivateControlDir controlDir + let desired ← desiredConfig home root backend + let owned ← withProjectControl root (explicitControlDir? := some controlDir) fun control => + startOwnedProjectDaemon control desired opts + let exitCodeRef ← IO.mkRef (none : Option UInt32) + try + act { + client := owned.client + root + controlDir + daemonId := owned.entry.daemonId + child := owned.child + exitCodeRef + } + finally + finishOwnedProjectDaemon root controlDir owned exitCodeRef + +private def lookupProjectDaemon + (root : System.FilePath) + (backend? : Option Backend := none) + (explicitControlDir? : Option System.FilePath := none) : IO SelectedProjectDaemon := do + let control ← projectControl root explicitControlDir? + match ← observeProjectRegistryAt root control.registry with + | .live entry => + let workspace ← selectWorkspaceBackend root entry backend? + pure { client := ← projectDaemonClient entry workspace control.dir, workspace } + | .absent => + throw <| IO.userError (missingOwnerMessage root backend?) + | .draining entry => throw <| IO.userError (drainingOwnerMessage root entry) + | .legacy => + throw <| IO.userError <| registryReadRecoveryMessage root .legacy + | .unsupported schemaVersion => + throw <| IO.userError <| registryReadRecoveryMessage root (.unsupported schemaVersion) + | .malformed detail => + throw <| IO.userError <| registryReadRecoveryMessage root (.malformed detail) + | .unusable entry reason => + throw <| IO.userError <| generationRecoveryMessage root entry reason + +def withProjectDaemon + (root : System.FilePath) + (backend : Backend) + (act : ProjectDaemonClient → IO α) + (explicitControlDir? : Option System.FilePath := none) : IO α := do + act (← lookupProjectDaemon root (some backend) explicitControlDir?).client + +def withExistingProjectDaemon + (root : System.FilePath) + (act : ProjectDaemonClient → IO α) + (explicitControlDir? : Option System.FilePath := none) : IO α := do + act (← lookupProjectDaemon root (explicitControlDir? := explicitControlDir?)).client + +/-- Select one live workspace session without resolving local toolchain or bundle configuration. -/ +def withSelectedProjectDaemon + (root : System.FilePath) + (act : SelectedProjectDaemon → IO α) + (explicitControlDir? : Option System.FilePath := none) : IO α := do + act (← lookupProjectDaemon root (explicitControlDir? := explicitControlDir?)) end Beam.Cli diff --git a/Beam/Cli/Feedback.lean b/Beam/Cli/Feedback.lean index 8c5544a9..f668570e 100644 --- a/Beam/Cli/Feedback.lean +++ b/Beam/Cli/Feedback.lean @@ -8,12 +8,13 @@ import Lean import Beam.Broker.Client import Beam.Cli.Args import Beam.Cli.DaemonManager -import Beam.Cli.Lock import Beam.Cli.Output import Beam.Cli.Project import Beam.Daemon.Debug +import Beam.Daemon.Paths import Beam.Feedback import Beam.Feedback.Broker +import Beam.System import Beam.Version open Lean @@ -92,34 +93,56 @@ private def versionIdentityJson (home : System.FilePath) : IO Json := do private def collectDaemonPayload (root : System.FilePath) + (explicitControlDir? : Option System.FilePath) (warnings : Array String) : IO (Json × Json × Array String) := do - match ← registryLiveFor root with - | none => - pure (Json.null, Json.null, warnings.push "no live Beam daemon was available for stats/open-files") - | some entry => + match ← observeProjectRegistry root explicitControlDir? with + | .live entry => match Beam.Daemon.registryEndpoint? entry with | none => pure (Json.null, Json.null, warnings.push "Beam daemon registry did not contain a valid endpoint") | some endpoint => - let statsResp ← sendRequest endpoint { + let some workspace ← Beam.Cli.sessionWorkspaceForRoot? entry root + | return (Json.null, Json.null, + warnings.push "the Beam session does not contain the selected project root") + let controlDir ← Beam.Daemon.controlDirFor root explicitControlDir? + let client : ProjectDaemonClient := { + endpoint + capability := entry.capability + workspaceId := workspace.workspaceId + controlDir + } + let statsResp ← sendRequest endpoint <| client.authorize { op := .stats - workspaceId? := some Beam.Cli.projectDaemonWorkspaceId + workspaceId? := some client.workspaceId root? := some root.toString } let (stats, warnings) := Beam.Feedback.responsePayloadOrWarning "stats" statsResp warnings - let openResp ← sendRequest endpoint { + let openResp ← sendRequest endpoint <| client.authorize { op := .openDocs - workspaceId? := some Beam.Cli.projectDaemonWorkspaceId + workspaceId? := some client.workspaceId root? := some root.toString } let (openDocs, warnings) := Beam.Feedback.responsePayloadOrWarning "open-files" openResp warnings pure (stats, openDocs, warnings) + | .absent => + pure (Json.null, Json.null, warnings.push "no live Beam daemon was available for stats/open-files") + | .draining _ => + pure (Json.null, Json.null, warnings.push "the Beam daemon is draining") + | .legacy => + pure (Json.null, Json.null, warnings.push "the Beam daemon registry is legacy and unsupported") + | .unsupported _ => + pure (Json.null, Json.null, warnings.push "the Beam daemon registry schema is unsupported") + | .malformed detail => + pure (Json.null, Json.null, warnings.push s!"the Beam daemon registry is malformed: {detail}") + | .unusable _ reason => + pure (Json.null, Json.null, warnings.push s!"the Beam daemon registry is unsafe: {reason.message}") private def collectNonConfidential (home : System.FilePath) (root? : Option System.FilePath) + (explicitControlDir? : Option System.FilePath) (warnings : Array String) : IO Beam.Feedback.Collection := do - let generatedAt ← utcTimestamp + let generatedAt ← Beam.utcTimestamp let identity ← versionIdentityJson home let (stats, openDocs, daemon, warnings) ← match root? with @@ -127,9 +150,8 @@ private def collectNonConfidential pure (Json.null, Json.null, Json.null, warnings.push "could not infer project root; daemon debug context was not collected") | some root => do - let daemon ← daemonDebugContextJson root - let warnings := warnings ++ Beam.Daemon.daemonDebugWarnings daemon - let (stats, openDocs, warnings) ← collectDaemonPayload root warnings + let daemon ← Beam.Daemon.daemonDebugContextJson root explicitControlDir? + let (stats, openDocs, warnings) ← collectDaemonPayload root explicitControlDir? warnings pure (stats, openDocs, daemon, warnings) pure { generatedAt @@ -145,7 +167,7 @@ private def collectNonConfidential private def collectConfidential : IO Beam.Feedback.Collection := do pure { - generatedAt := ← utcTimestamp + generatedAt := ← Beam.utcTimestamp data := Json.mkObj [("identity", confidentialIdentityJson)] } @@ -196,12 +218,13 @@ def run (home : System.FilePath) (cliOpts : CliOptions) (args : List String) : I else pure (none, #[]) let collection ← - if input.confidential then collectConfidential else collectNonConfidential home root? warnings + if input.confidential then collectConfidential + else collectNonConfidential home root? cliOpts.explicitControlDir? warnings let allowedRoots ← if Beam.Feedback.Internal.needsEvidenceRoots input then match root? with | some root => do - let control ← controlDir root + let control ← Beam.Daemon.controlDirFor root cliOpts.explicitControlDir? pure #[root, control] | none => pure #[] else diff --git a/Beam/Cli/Info.lean b/Beam/Cli/Info.lean index eb3bd855..7faa7ee3 100644 --- a/Beam/Cli/Info.lean +++ b/Beam/Cli/Info.lean @@ -10,6 +10,8 @@ import Beam.Cli.InstallLayout import Beam.Cli.Output import Beam.Cli.Project import Beam.Cli.RuntimeBundle +import Beam.Daemon.Debug +import Beam.Daemon.Paths import Beam.Version open Lean @@ -143,8 +145,10 @@ private def printRocqDoctorInfo (home root : System.FilePath) : IO Unit := do IO.println s!"daemon binary: {paths.daemon}" IO.println s!"client binary: {paths.client}" -def daemonFailureIncidentDoctorLines (root : System.FilePath) : IO (List String) := do - let incidents ← recentDaemonFailureIncidentPaths root +def daemonFailureIncidentDoctorLines + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO (List String) := do + let incidents ← Beam.Daemon.recentDaemonFailureIncidentPaths root 5 explicitControlDir? if incidents.isEmpty then pure ["daemon incidents: none"] else @@ -152,8 +156,10 @@ def daemonFailureIncidentDoctorLines (root : System.FilePath) : IO (List String) s!"daemon incidents: {incidents.size} recent" :: (incidents.toList.map fun path => s!"daemon incident: {path}") -private def printDaemonFailureIncidentDoctorInfo (root : System.FilePath) : IO Unit := do - for line in ← daemonFailureIncidentDoctorLines root do +private def printDaemonFailureIncidentDoctorInfo + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO Unit := do + for line in ← daemonFailureIncidentDoctorLines root explicitControlDir? do IO.println line def doctor (home : System.FilePath) (opts : CliOptions) (backend : Backend) : IO Unit := do @@ -163,25 +169,32 @@ def doctor (home : System.FilePath) (opts : CliOptions) (backend : Backend) : IO match backend with | .lean => printLeanDoctorInfo home root | .rocq => printRocqDoctorInfo home root - let registry ← registryPath root + let registry ← Beam.Daemon.registryPathFor root opts.explicitControlDir? IO.println s!"registry: {registry}" - match ← registryLiveFor root with - | some entry => + match ← observeProjectRegistry root opts.explicitControlDir? with + | .live entry => IO.println "daemon status: live" IO.println s!"daemon pid: {entry.pid}" - if let some pidNamespace := entry.pidNamespace? then - IO.println s!"daemon pid namespace: {pidNamespace}" if let some endpoint := Beam.Daemon.registryEndpoint? entry then IO.println s!"daemon endpoint: {Beam.Daemon.endpointSummary endpoint}" else IO.println "daemon endpoint: invalid" IO.println s!"daemon config hash: {entry.configHash}" - | none => - if ← registry.pathExists then - IO.println "daemon status: stale" - else - IO.println "daemon status: absent" - printDaemonFailureIncidentDoctorInfo root + | .draining entry => + IO.println "daemon status: draining" + IO.println s!"daemon generation: {entry.daemonId}" + | .absent => IO.println "daemon status: absent" + | .legacy => IO.println "daemon status: legacy registry" + | .unsupported schemaVersion => + IO.println "daemon status: unsupported registry" + IO.println s!"registry schema version: {schemaVersion}" + | .malformed detail => + IO.println "daemon status: malformed registry" + IO.println s!"registry error: {detail}" + | .unusable _ reason => + IO.println "daemon status: unsafe" + IO.println s!"daemon safety error: {reason.message}" + printDaemonFailureIncidentDoctorInfo root opts.explicitControlDir? def printValidatedToolchains (home : System.FilePath) (backendName : String) : IO Unit := do match backendName with diff --git a/Beam/Cli/InstallPrune.lean b/Beam/Cli/InstallPrune.lean index 863eeeda..a6e81367 100644 --- a/Beam/Cli/InstallPrune.lean +++ b/Beam/Cli/InstallPrune.lean @@ -31,6 +31,56 @@ private structure InstallPrunePlan where oldRuntimes : Array System.FilePath := #[] staleBundles : Array System.FilePath := #[] +private def installLockPollMs : Nat := + 100 + +/-- +Coordinate pruning with the bootstrap shell installer, which owns `.install-lock` as a directory. + +Unlike the old generic directory lock, this compatibility boundary never reaps a purportedly stale +owner: `mkdir` is the only acquisition operation and only the process that created the directory +removes it. A crashed installer therefore fails closed and requires explicit operator recovery. +-/ +private partial def acquireInstallLockUntil + (lockDir : System.FilePath) + (startedNanos deadlineNanos timeoutMs : Nat) : IO Unit := do + let acquired ← + try + IO.FS.createDir lockDir + pure true + catch + | .alreadyExists .. => pure false + | error => throw error + if acquired then + let selfPid ← IO.Process.getPID + IO.FS.writeFile (lockDir / "pid") s!"{selfPid}\n" + return + let now ← IO.monoNanosNow + if now >= deadlineNanos then + let waitedMs := (now - startedNanos) / 1000000 + throw <| IO.userError <| + s!"timed out after {waitedMs} ms waiting for Beam lock {lockDir}; timeout: {timeoutMs} ms" + IO.sleep installLockPollMs.toUInt32 + acquireInstallLockUntil lockDir startedNanos deadlineNanos timeoutMs + +private def releaseInstallLock (lockDir : System.FilePath) : IO Unit := do + let pidPath := lockDir / "pid" + if ← pidPath.pathExists then + IO.FS.removeFile pidPath + IO.FS.removeDir lockDir + +private def withInstallLockTimeout + (lockDir : System.FilePath) + (timeoutMs : Nat) + (act : IO α) : IO α := do + let startedNanos ← IO.monoNanosNow + acquireInstallLockUntil lockDir startedNanos + (startedNanos + timeoutMs * 1000000) timeoutMs + try + act + finally + releaseInstallLock lockDir + private def installPruneUsage : String := "usage: lean-beam prune [--apply] [--bundles]" @@ -249,7 +299,7 @@ def runInstallPrune (home : System.FilePath) (args : List String) : IO Unit := d IO.println installPruneHelp return let ctx ← resolveInstallPruneContext home - withLockTimeout (ctx.installRoot / ".install-lock") 1000 do + withInstallLockTimeout (ctx.installRoot / ".install-lock") 1000 do validateInstallPruneContext ctx let plan ← installPrunePlan ctx opts printInstallPrunePlan ctx opts plan diff --git a/Beam/Cli/Lock.lean b/Beam/Cli/Lock.lean index c672ff6a..f5b6f481 100644 --- a/Beam/Cli/Lock.lean +++ b/Beam/Cli/Lock.lean @@ -12,137 +12,82 @@ open Lean namespace Beam.Cli -def trimLine (text : String) : String := - Beam.trimLine text - -def readCmdTrim (cmd : String) (args : Array String := #[]) (cwd? : Option System.FilePath := none) : IO String := do - Beam.readCmdTrim cmd args cwd? - -def commandAvailable (cmd : String) (args : Array String := #["--help"]) : IO Bool := do - Beam.commandAvailable cmd args - -def killCommand : IO String := do - Beam.killCommand - -def pidAlive (pid : Nat) : IO Bool := do - Beam.pidAlive pid - private def lockPollMs : Nat := 100 -private def readLockPid? (lockDir : System.FilePath) : IO (Option Nat) := do - try - let pidPath := lockDir / "pid" - if ← Beam.regularNonSymlinkFile pidPath then - let text ← IO.FS.readFile pidPath - pure <| trimLine text |>.toNat? - else - pure none - catch _ => - pure none - -private def lockOwnerDescription : Option Nat → String - | some pid => s!"pid {pid}" - | none => "unknown owner" +private structure LockDeadline where + timeoutMs : Nat + startedNanos : Nat + deadlineNanos : Nat private def lockTimeoutMessage - (lockDir : System.FilePath) - (ownerPid? : Option Nat) + (lockPath : System.FilePath) (waitedMs timeoutMs : Nat) : String := - s!"timed out after {waitedMs} ms waiting for Beam lock {lockDir}; " ++ - s!"lock owner: {lockOwnerDescription ownerPid?}; timeout: {timeoutMs} ms" + s!"timed out after {waitedMs} ms waiting for Beam lock {lockPath}; " ++ + s!"timeout: {timeoutMs} ms" -private def removeStaleLock? (lockDir : System.FilePath) (ownerPid? : Option Nat) : IO Bool := do - match ownerPid? with - | some ownerPid => - if !(← pidAlive ownerPid) then - if ← lockDir.pathExists then - IO.FS.removeDirAll lockDir - pure true - else - pure false - | none => - pure false - -private partial def acquireLockCore - (lockDir : System.FilePath) - (timeoutMs? : Option Nat) - (waitedMs : Nat := 0) : IO Unit := do - if let some parent := lockDir.parent then +/-- Ensure the stable lock file can be opened without replacing its inode. -/ +private def ensureLockParent (lockPath : System.FilePath) : IO Unit := do + if let some parent := lockPath.parent then IO.FS.createDirAll parent - let selfPid ← IO.Process.getPID - let acquired ← - try - IO.FS.createDir lockDir - pure true - catch - | .alreadyExists .. => - pure false - | error => - throw error - if acquired then - try - IO.FS.writeFile (lockDir / "pid") s!"{selfPid}\n" - return - catch error => - try - if ← lockDir.pathExists then - IO.FS.removeDirAll lockDir - catch cleanupError => - throw <| IO.userError <| - s!"failed to publish Beam lock owner at {lockDir}: {error}; " ++ - s!"also failed to remove the acquired lock: {cleanupError}" - throw error - else - let ownerPid? ← readLockPid? lockDir - if ← removeStaleLock? lockDir ownerPid? then - acquireLockCore lockDir timeoutMs? waitedMs - else - match timeoutMs? with - | some timeoutMs => - if waitedMs >= timeoutMs then - throw <| IO.userError (lockTimeoutMessage lockDir ownerPid? waitedMs timeoutMs) - | none => - pure () - IO.sleep lockPollMs.toUInt32 - acquireLockCore lockDir timeoutMs? (waitedMs + lockPollMs) -def acquireLock (lockDir : System.FilePath) : IO Unit := - acquireLockCore lockDir none +private def withAcquiredLock + (lockPath : System.FilePath) + (mode : IO.FS.Mode) + (acquire : IO.FS.Handle → IO Unit) + (act : IO α) : IO α := do + IO.FS.withFile lockPath mode fun handle => do + acquire handle + try + act + finally + handle.unlock + +private partial def acquireLockUntil + (handle : IO.FS.Handle) + (lockPath : System.FilePath) + (deadline : LockDeadline) : IO Unit := do + if ← handle.tryLock then + return + let now ← IO.monoNanosNow + if now >= deadline.deadlineNanos then + let waitedMs := (now - deadline.startedNanos) / 1000000 + throw <| IO.userError <| + lockTimeoutMessage lockPath waitedMs deadline.timeoutMs + IO.sleep lockPollMs.toUInt32 + acquireLockUntil handle lockPath deadline + +/-- Run `act` while holding an unbounded kernel-backed file lock. -/ +def withLock (lockPath : System.FilePath) (act : IO α) : IO α := do + ensureLockParent lockPath + withAcquiredLock lockPath .append (·.lock) act + +/-- Run `act` while holding a kernel-backed file lock until an absolute monotonic deadline. -/ +def withLockTimeout (lockPath : System.FilePath) (timeoutMs : Nat) (act : IO α) : IO α := do + ensureLockParent lockPath + let startedNanos ← IO.monoNanosNow + let deadline := { + timeoutMs + startedNanos + deadlineNanos := startedNanos + timeoutMs * 1000000 + } + withAcquiredLock lockPath .append (fun handle => acquireLockUntil handle lockPath deadline) act /-- -Acquire a directory lock, but fail with lock owner diagnostics after `timeoutMs`. +Run `act` under a kernel-backed lock without creating the lock's parent directory. -The unbounded `acquireLock` remains available for long-running build/install locks. This bounded -variant is for short project-control critical sections where silent infinite waiting hides daemon -or wrapper failures. +This is reserved for teardown after an owned project root may have disappeared. -/ -def acquireLockTimeout (lockDir : System.FilePath) (timeoutMs : Nat) : IO Unit := - acquireLockCore lockDir (some timeoutMs) - -def releaseLock (lockDir : System.FilePath) : IO Unit := do - if ← lockDir.pathExists then - IO.FS.removeDirAll lockDir - -def withLock (lockDir : System.FilePath) (act : IO α) : IO α := do - acquireLock lockDir - try - act - finally - releaseLock lockDir - -/-- Run `act` while holding a bounded directory lock. -/ -def withLockTimeout (lockDir : System.FilePath) (timeoutMs : Nat) (act : IO α) : IO α := do - acquireLockTimeout lockDir timeoutMs - try - act - finally - releaseLock lockDir - -def currentPidNamespace? : IO (Option String) := do - Beam.currentPidNamespace? - -def utcTimestamp : IO String := do - Beam.utcTimestamp +def withExistingLockTimeout + (lockPath : System.FilePath) + (timeoutMs : Nat) + (act : IO α) : IO α := do + let startedNanos ← IO.monoNanosNow + let deadline := { + timeoutMs + startedNanos + deadlineNanos := startedNanos + timeoutMs * 1000000 + } + withAcquiredLock lockPath .readWrite (fun handle => acquireLockUntil handle lockPath deadline) act end Beam.Cli diff --git a/Beam/Cli/Project.lean b/Beam/Cli/Project.lean index aa6bd5d3..4a496c2f 100644 --- a/Beam/Cli/Project.lean +++ b/Beam/Cli/Project.lean @@ -11,6 +11,7 @@ import Beam.Cli.RuntimeBundle import Beam.Lean.Workspace import Beam.Path import Beam.Project +import Beam.System open Lean @@ -86,10 +87,10 @@ def leanToolchain (root : System.FilePath) : IO String := do let path := root / "lean-toolchain" unless ← path.pathExists do throw <| IO.userError s!"missing lean-toolchain in {root}" - pure <| trimLine (← IO.FS.readFile path) + pure <| Beam.trimLine (← IO.FS.readFile path) def leanBin (root : System.FilePath) : IO String := - readCmdTrim "elan" #["which", "lean"] (some root) + Beam.readCmdTrim "elan" #["which", "lean"] (some root) def rocqCandidates (root : System.FilePath) : List System.FilePath := [root / "_opam" / "bin" / "coq-lsp", root / "_opam" / "_opam" / "bin" / "coq-lsp"] @@ -101,7 +102,7 @@ def maybeRocqCmd (root : System.FilePath) : IO (Option String) := do match ← IO.getEnv "BEAM_ROCQ_CMD" with | some cmd => pure (some cmd) | none => - if ← commandAvailable "coq-lsp" then + if ← Beam.commandAvailable "coq-lsp" then pure (some "coq-lsp") else pure none diff --git a/Beam/Cli/RuntimeBundle/Build.lean b/Beam/Cli/RuntimeBundle/Build.lean index 4a006c08..b5755b28 100644 --- a/Beam/Cli/RuntimeBundle/Build.lean +++ b/Beam/Cli/RuntimeBundle/Build.lean @@ -270,7 +270,7 @@ def ensureToolchainBundle (root home : System.FilePath) (toolchain : String) : I match ← existingToolchainBundleInAnyForFingerprint? (← installBundleCacheRoots) home toolchain fingerprint with | some bundle => pure bundle | none => - let cacheRoot ← runtimeBundleCacheRoot root + let cacheRoot ← runtimeBundleCacheRootForWrite root ensureToolchainBundleInForFingerprint cacheRoot home toolchain fingerprint def ensureDefaultDaemonHelpers (home : System.FilePath) : IO BundlePaths := do diff --git a/Beam/Cli/RuntimeBundle/Fingerprint.lean b/Beam/Cli/RuntimeBundle/Fingerprint.lean index b0e7e91a..1236684e 100644 --- a/Beam/Cli/RuntimeBundle/Fingerprint.lean +++ b/Beam/Cli/RuntimeBundle/Fingerprint.lean @@ -5,9 +5,9 @@ Author: Emilio J. Gallego Arias -/ import Lean -import Beam.Cli.Lock import Beam.Cli.RuntimeBundle.Source import Beam.Cli.RuntimeBundle.ToolchainPolicy +import Beam.System open Lean @@ -21,12 +21,12 @@ structure ToolchainFingerprint where deriving BEq, Repr, FromJson, ToJson def bundlePlatform : IO String := do - let system := ← readCmdTrim "uname" #["-s"] - let machine := ← readCmdTrim "uname" #["-m"] + let system := ← Beam.readCmdTrim "uname" #["-s"] + let machine := ← Beam.readCmdTrim "uname" #["-m"] pure s!"{system.toLower}-{machine.toLower}" def ensureElan : IO Unit := do - unless ← commandAvailable "elan" do + unless ← Beam.commandAvailable "elan" do throw <| IO.userError "missing elan on PATH" private def readRequiredToolchainCmdTrim (toolchain exe : String) (args : Array String := #[]) : @@ -46,7 +46,7 @@ private def readRequiredToolchainCmdTrim (toolchain exe : String) (args : Array "stderr:", if out.stderr.trimAscii.isEmpty then "(empty)" else out.stderr ] - let text := trimLine out.stdout + let text := Beam.trimLine out.stdout if text.isEmpty then throw <| IO.userError s!"failed to fingerprint Lean toolchain {toolchain}: `elan run {toolchain} {exe}` returned empty stdout" diff --git a/Beam/Cli/RuntimeBundle/Metadata.lean b/Beam/Cli/RuntimeBundle/Metadata.lean index 883cb972..465daba8 100644 --- a/Beam/Cli/RuntimeBundle/Metadata.lean +++ b/Beam/Cli/RuntimeBundle/Metadata.lean @@ -8,6 +8,7 @@ import Lean import Beam.Cli.RuntimeBundle.Fingerprint import Beam.Cli.RuntimeBundle.Paths import Beam.Path +import Beam.System open Lean @@ -121,6 +122,6 @@ def writeBundleMetadata (bundleDir : System.FilePath) (toolchain srcHash : Strin if let some parent := path.parent then IO.FS.createDirAll parent IO.FS.writeFile path - ((bundleMetadataJson toolchain srcHash fingerprint workspace (← utcTimestamp)).pretty ++ "\n") + ((bundleMetadataJson toolchain srcHash fingerprint workspace (← Beam.utcTimestamp)).pretty ++ "\n") end Beam.Cli diff --git a/Beam/Cli/RuntimeBundle/Paths.lean b/Beam/Cli/RuntimeBundle/Paths.lean index 0264a59f..22e89efc 100644 --- a/Beam/Cli/RuntimeBundle/Paths.lean +++ b/Beam/Cli/RuntimeBundle/Paths.lean @@ -103,6 +103,37 @@ def runtimeBundleCacheRoot (root : System.FilePath) : IO System.FilePath := do | some path => pure (System.FilePath.mk path) | none => pure (beamStateDir root / runtimeBundlesDirName) +private def privateBeamStateDirRights : IO.FileRight := { + user := { read := true, write := true, execution := true } +} + +/-- +Create the project-local Beam state leaf privately when runtime bundle construction is the first +Beam operation for a project. Existing state directories are never chmodded: wrapper ownership +applies its stricter control-directory validation separately. +-/ +def runtimeBundleCacheRootForWrite (root : System.FilePath) : IO System.FilePath := do + match ← IO.getEnv "BEAM_BUNDLE_DIR" with + | some path => pure (System.FilePath.mk path) + | none => + let stateDir := beamStateDir root + unless ← stateDir.pathExists do + try + IO.FS.createDir stateDir + catch + | .alreadyExists .. => + return stateDir / runtimeBundlesDirName + | err => throw err + try + IO.setAccessRights stateDir privateBeamStateDirRights + catch err => + try + IO.FS.removeDir stateDir + catch _ => + pure () + throw err + pure (stateDir / runtimeBundlesDirName) + def validatedLeanToolchainsPath (home : System.FilePath) : System.FilePath := home / "validated-lean-toolchains" diff --git a/Beam/Cli/Usage.lean b/Beam/Cli/Usage.lean index e8f13872..dd434bf8 100644 --- a/Beam/Cli/Usage.lean +++ b/Beam/Cli/Usage.lean @@ -13,28 +13,29 @@ def usage : String := "usage:", " beam --version", " beam version", - " beam [--root PATH] [--port N] ensure [lean|rocq] [--hold]", - " beam [--root PATH] [--port N] lean-run-at [--stdin | --text-file | -- | ]", - " beam [--root PATH] [--port N] lean-run-at-handle [--stdin | --text-file | -- | ]", - " beam [--root PATH] [--port N] lean-hover ", - " beam [--root PATH] [--port N] lean-signature-help ", - " beam [--root PATH] [--port N] lean-definition ", - " beam [--root PATH] [--port N] lean-references [--include-declaration|--exclude-declaration]", - " beam [--root PATH] [--port N] lean-document-symbols ", - " beam [--root PATH] [--port N] lean-workspace-symbols ", - " beam [--root PATH] [--port N] lean-goals before|after ", - " beam [--root PATH] [--port N] lean-todo [--kind ...] [--suggest none|basic]", - " beam [--root PATH] [--port N] lean-run-with > [--stdin | --text-file | -- | ]", - " beam [--root PATH] [--port N] lean-run-with-linear > [--stdin | --text-file | -- | ]", - " beam [--root PATH] [--port N] lean-release >", - " beam [--root PATH] [--port N] lean-update ", - " beam [--root PATH] [--port N] lean-sync [+all-diagnostics]", - " beam [--root PATH] [--port N] lean-refresh [+all-diagnostics]", - " beam [--root PATH] [--port N] lean-save [+all-diagnostics]", - " beam [--root PATH] [--port N] lean-close ", - " beam [--root PATH] [--port N] lean-close-save [+all-diagnostics]", - " beam [--root PATH] [--port N] rocq-goals-after [text...]", - " beam [--root PATH] [--port N] rocq-goals-prev [text...]", + " beam [--root PATH] ensure [lean|rocq]", + " beam [--root PATH] [--port N] ensure [lean|rocq] --hold", + " beam [--root PATH] lean-run-at [--stdin | --text-file | -- | ]", + " beam [--root PATH] lean-run-at-handle [--stdin | --text-file | -- | ]", + " beam [--root PATH] lean-hover ", + " beam [--root PATH] lean-signature-help ", + " beam [--root PATH] lean-definition ", + " beam [--root PATH] lean-references [--include-declaration|--exclude-declaration]", + " beam [--root PATH] lean-document-symbols ", + " beam [--root PATH] lean-workspace-symbols ", + " beam [--root PATH] lean-goals before|after ", + " beam [--root PATH] lean-todo [--kind ...] [--suggest none|basic]", + " beam [--root PATH] lean-run-with > [--stdin | --text-file | -- | ]", + " beam [--root PATH] lean-run-with-linear > [--stdin | --text-file | -- | ]", + " beam [--root PATH] lean-release >", + " beam [--root PATH] lean-update ", + " beam [--root PATH] lean-sync [+all-diagnostics]", + " beam [--root PATH] lean-refresh [+all-diagnostics]", + " beam [--root PATH] lean-save [+all-diagnostics]", + " beam [--root PATH] lean-close ", + " beam [--root PATH] lean-close-save [+all-diagnostics]", + " beam [--root PATH] rocq-goals-after [text...]", + " beam [--root PATH] rocq-goals-prev [text...]", " beam [--root PATH] feedback-report --stdin|--input [--bundle none|dir|zip] [--output-dir ] [--no-redact]", " beam bundle-install ", " beam install-prune [--apply] [--bundles]", @@ -46,7 +47,11 @@ def usage : String := " beam [--root PATH] stats", " beam [--root PATH] reset-stats", " beam [--root PATH] shutdown", + " beam [--root PATH] [--control-dir DIR] recover --generation ID | --force", + " beam --root PATH [--control-dir DIR] request-stream ", "", + "Project-session commands accept --control-dir DIR as an exact alternate control selection.", + "Use the same --root and --control-dir for owner, attachment, diagnostics, shutdown, and recovery.", "Beam never applies source edits to `.lean` files on disk; the client applies source edits.", "Lean edit loop: save the file, then run lean-update for a broker document version.", "Run lean-sync when you need the diagnostics/readiness barrier. lean-save is lean-sync plus a", @@ -59,8 +64,10 @@ def usage : String := "For multiline text-carrying Lean probes, prefer --stdin or --text-file ; use -- before", "text that itself starts with --.", "For handle-based commands, use --handle-file when you do not want to inline handle json.", - "Use ensure --hold when a PID-isolated command runner needs one foreground wrapper process", - "to keep a newly-started daemon alive across separate wrapper invocations.", + "Wrapper commands require one live session owner. Start ensure --hold in a foreground process", + "and keep it running across wrapper invocations; interrupt it or run shutdown when finished.", + "Plain ensure checks and warms that owned session but never starts one implicitly.", + "Abnormal session state remains fenced until recover --generation ID quarantines that record.", "Beam does not upload or submit feedback. Use feedback-report to print a pasteable report", "with cheap version, stats, open-files, daemon registry, and daemon incident context.", "Review non-confidential reports before posting because they may contain project context and", @@ -76,7 +83,7 @@ def usage : String := "Wrapper diagnostics and progress are human-facing on stderr.", "Set BEAM_DEBUG_TEXT=1 to print the exact escaped text and UTF-8 bytes sent for text-carrying", "Lean probe requests.", - "For machine-readable streaming diagnostics/progress, use beam-client request-stream.", + "For machine-readable streaming diagnostics/progress, use beam --root PATH request-stream.", "For the Lean workflow contract and anti-patterns, see skills/lean-beam/SKILL.md." ] diff --git a/Beam/Daemon/Debug.lean b/Beam/Daemon/Debug.lean index c1735fb6..80eabc5a 100644 --- a/Beam/Daemon/Debug.lean +++ b/Beam/Daemon/Debug.lean @@ -5,48 +5,19 @@ Author: Emilio J. Gallego Arias -/ import Lean -import Beam.Daemon.Protocol +import Beam.Daemon.Paths +import Beam.Daemon.Registry import Beam.System open Lean namespace Beam.Daemon -private def beamStateDir (root : System.FilePath) : System.FilePath := - root / ".beam" - -def controlDir (root : System.FilePath) : IO System.FilePath := do - match ← IO.getEnv "BEAM_CONTROL_DIR" with - | some dir => - let tag := toString (hash root.toString) - pure (System.FilePath.mk dir / tag) - | none => - pure (beamStateDir root) - -def registryPath (root : System.FilePath) : IO System.FilePath := do - pure ((← controlDir root) / "beam-daemon.json") - -def readRegistry? (root : System.FilePath) : IO (Option RegistryEntry) := do - let path ← registryPath root - unless ← path.pathExists do - return none - try - let text ← IO.FS.readFile path - let json ← IO.ofExcept <| Json.parse text - let entry ← IO.ofExcept <| fromJson? json - pure (some entry) - catch _ => - pure none - -def daemonStartupLogPath (root : System.FilePath) : IO System.FilePath := do - pure ((← controlDir root) / "beam-daemon-startup.log") - -def daemonFailureIncidentDir (root : System.FilePath) : IO System.FilePath := do - pure ((← controlDir root) / "daemon-failures") - -def daemonFailureIncidentEntries (root : System.FilePath) : IO (Array IO.FS.DirEntry) := do +def daemonFailureIncidentEntries + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO (Array IO.FS.DirEntry) := do try - let dir ← daemonFailureIncidentDir root + let dir ← daemonFailureIncidentDirFor root explicitControlDir? unless ← dir.pathExists do return #[] let entries ← dir.readDir @@ -55,16 +26,22 @@ def daemonFailureIncidentEntries (root : System.FilePath) : IO (Array IO.FS.DirE catch _ => pure #[] -def recentDaemonFailureIncidentPaths (root : System.FilePath) (limit : Nat := 5) : +def recentDaemonFailureIncidentPaths + (root : System.FilePath) + (limit : Nat := 5) + (explicitControlDir? : Option System.FilePath := none) : IO (Array System.FilePath) := do - let entries ← daemonFailureIncidentEntries root + let entries ← daemonFailureIncidentEntries root explicitControlDir? let keep := min limit entries.size let recent := entries.toList.drop (entries.size - keep) pure <| recent.foldl (fun acc entry => acc.push entry.path) #[] -private def recentDaemonFailureIncidentJson (root : System.FilePath) (limit : Nat := 5) : +private def recentDaemonFailureIncidentJson + (root : System.FilePath) + (limit : Nat := 5) + (explicitControlDir? : Option System.FilePath := none) : IO (Array Json) := do - let paths ← recentDaemonFailureIncidentPaths root limit + let paths ← recentDaemonFailureIncidentPaths root limit explicitControlDir? let mut incidents := #[] for path in paths do let payload ← @@ -90,26 +67,17 @@ private def tailLines (text : String) (count : Nat := 20) : String := let keep := min count lines.length String.intercalate "\n" <| lines.drop (lines.length - keep) -def registryEndpointSummary (entry : RegistryEntry) : String := +def registryEndpointSummary (entry : SessionDescriptor) : String := match registryEndpoint? entry with | some endpoint => endpointSummary endpoint | none => "invalid" -def registryPidStatus (entry : RegistryEntry) : IO String := do - if entry.pid == 0 then - pure "unknown" - else - try - if ← Beam.pidAlive entry.pid then - pure "alive" - else - pure "not alive" - catch _ => - pure "unavailable" - -def startupLogTail? (root : System.FilePath) : IO (Option (System.FilePath × String)) := do +def startupLogTail? + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : + IO (Option (System.FilePath × String)) := do try - let logPath ← daemonStartupLogPath root + let logPath ← daemonStartupLogPathFor root explicitControlDir? if ← logPath.pathExists then let logText := Beam.trimLine (← IO.FS.readFile logPath) if logText.isEmpty then @@ -121,78 +89,65 @@ def startupLogTail? (root : System.FilePath) : IO (Option (System.FilePath × St catch _ => pure none -private def jsonStringField? (json : Json) (field : String) : Option String := - match json.getObjValAs? String field with - | .ok value => some value - | .error _ => none - -private def jsonNonNullField (json : Json) (field : String) : Bool := - match json.getObjVal? field with - | .ok Json.null => false - | .ok _ => true - | .error _ => false - -def daemonDebugWarnings (debug : Json) : Array String := Id.run do - let mut warnings := #[] - let recoveryHint := "Run `lean-beam shutdown`, then `lean-beam ensure` from the project root to refresh daemon registry state." - if jsonNonNullField debug "registry" then - match jsonStringField? debug "registryPidStatus" with - | some "not alive" => - let detail := - if jsonNonNullField debug "registryEndpoint" then - " while a registry endpoint is recorded" - else - "" - warnings := warnings.push - s!"Beam daemon registry pid is not alive{detail}; stats/open-files may come from a live endpoint with stale registry metadata. {recoveryHint}" - | some "unavailable" => - warnings := warnings.push - s!"Beam could not verify the daemon registry pid; stats/open-files may reflect a daemon whose registry metadata cannot be trusted. {recoveryHint}" - | _ => - pure () - warnings - private def optionLine (label : String) : Option String → Option String | none => none | some value => some s!" {label}: {value}" -def daemonRegistryContext? (root : System.FilePath) : IO (Option String) := do +def daemonRegistryContext? + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO (Option String) := do try - match ← readRegistry? root with - | none => pure none - | some entry => - let path ← registryPath root - let pidStatus ← registryPidStatus entry + let path ← registryPathFor root explicitControlDir? + match ← readRegistryAt path with + | .absent => pure none + | .legacy => + pure <| some s!"Beam daemon registry ({path}):\n status: legacy\n detail: legacy registry has no schemaVersion" + | .unsupported schemaVersion => + let detail := (RegistryRead.unsupported schemaVersion).detail?.getD "unsupported registry" + pure <| some s!"Beam daemon registry ({path}):\n status: unsupported\n detail: {detail}" + | .malformed detail => + pure <| some s!"Beam daemon registry ({path}):\n status: malformed\n detail: {detail}" + | .current entry => + let workspaceLines := entry.workspaces.toList.flatMap fun workspace => + ([ + s!" workspace: {workspace.workspaceId}", + s!" root: {workspace.root}", + s!" configHash: {workspace.configHash}" + ] ++ + (optionLine " toolchain" workspace.toolchain?).toList ++ + (optionLine " bundleId" workspace.bundleId?).toList) let lines := ([ s!"Beam daemon registry ({path}):", + s!" schemaVersion: {entry.schemaVersion}", + s!" lifecycle: {repr entry.lifecycle}", s!" daemonId: {entry.daemonId}", - s!" pid: {entry.pid} ({pidStatus})", + s!" pid: {entry.pid} (diagnostic only)", s!" endpoint: {registryEndpointSummary entry}", s!" startedAt: {entry.startedAt}", - s!" configHash: {entry.configHash}", - s!" root: {entry.root}" - ] ++ - (optionLine "toolchain" entry.toolchain?).toList ++ - (optionLine "bundleId" entry.bundleId?).toList ++ - (optionLine "pidNamespace" entry.pidNamespace?).toList) + s!" configHash: {entry.configHash}" + ] ++ workspaceLines) pure <| some <| String.intercalate "\n" lines catch _ => pure none -def daemonDebugContextJson (root : System.FilePath) : IO Json := do - let registryFile ← registryPath root - let registry ← readRegistry? root - let registryPidStatus ← - match registry with - | some entry => some <$> registryPidStatus entry - | none => pure none - let startupLogTail ← startupLogTail? root - let incidents ← recentDaemonFailureIncidentJson root +def daemonDebugContextJson + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO Json := do + let registryFile ← registryPathFor root explicitControlDir? + let registryRead ← readRegistryAt registryFile + let registry := registryRead.entry? + let startupLogTail ← startupLogTail? root explicitControlDir? + let incidents ← recentDaemonFailureIncidentJson root 5 explicitControlDir? pure <| Json.mkObj <| [ ("registryPath", toJson registryFile.toString), - ("registry", match registry with | some entry => toJson entry | none => Json.null), - ("registryPidStatus", match registryPidStatus with | some status => toJson status | none => Json.null), + ("registryReadStatus", toJson registryRead.status), + ("registryReadDetail", match registryRead.detail? with + | some detail => toJson detail + | none => Json.null), + ("registry", match registry with + | some entry => entry.redactedJson + | none => Json.null), ("registryEndpoint", match registry.map registryEndpointSummary with | some endpoint => toJson endpoint | none => Json.null), ("recentDaemonIncidents", toJson incidents) ] ++ diff --git a/Beam/Daemon/Paths.lean b/Beam/Daemon/Paths.lean new file mode 100644 index 00000000..3bd0c1d7 --- /dev/null +++ b/Beam/Daemon/Paths.lean @@ -0,0 +1,65 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: Emilio J. Gallego Arias +-/ + +import Lean + +namespace Beam.Daemon + +private def beamStateDir (root : System.FilePath) : System.FilePath := + root / ".beam" + +/-- Stable FNV-1a tag used only for deterministic `BEAM_CONTROL_ROOT` discovery. -/ +private def controlRootTag (root : System.FilePath) : String := + let hash := root.toString.toUTF8.foldl + (fun acc byte => (acc ^^^ byte.toUInt64) * 1099511628211) + (14695981039346656037 : UInt64) + toString hash.toNat + +def controlDirFor + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO System.FilePath := do + match explicitControlDir? with + | some dir => pure dir + | none => + match ← IO.getEnv "BEAM_CONTROL_ROOT" with + | some base => + let base := System.FilePath.mk base + unless base.isAbsolute do + throw <| IO.userError + s!"BEAM_CONTROL_ROOT must be an absolute path, got '{base}'" + pure (base / controlRootTag root) + | none => + pure (beamStateDir root) + +/-- Resolve the default or environment-selected control directory for one CLI session. -/ +def controlDir (root : System.FilePath) : IO System.FilePath := + controlDirFor root + +def registryPathFor + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO System.FilePath := do + pure ((← controlDirFor root explicitControlDir?) / "beam-daemon.json") + +def registryPath (root : System.FilePath) : IO System.FilePath := do + registryPathFor root + +def daemonStartupLogPathFor + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO System.FilePath := do + pure ((← controlDirFor root explicitControlDir?) / "beam-daemon-startup.log") + +def daemonStartupLogPath (root : System.FilePath) : IO System.FilePath := + daemonStartupLogPathFor root + +def daemonFailureIncidentDirFor + (root : System.FilePath) + (explicitControlDir? : Option System.FilePath := none) : IO System.FilePath := do + pure ((← controlDirFor root explicitControlDir?) / "daemon-failures") + +def daemonFailureIncidentDir (root : System.FilePath) : IO System.FilePath := + daemonFailureIncidentDirFor root + +end Beam.Daemon diff --git a/Beam/Daemon/Protocol.lean b/Beam/Daemon/Protocol.lean index a9d6c2b6..50dc0ea6 100644 --- a/Beam/Daemon/Protocol.lean +++ b/Beam/Daemon/Protocol.lean @@ -15,24 +15,72 @@ namespace Beam.Daemon open Beam.Broker -structure RegistryEntry where - daemonId : String - pid : Nat - pidNamespace? : Option String := none - port? : Option Nat := none +def registrySchemaVersion : Nat := + 2 + +inductive RegistryLifecycle where + | live + | draining + deriving BEq, Repr + +instance : ToJson RegistryLifecycle where + toJson + | .live => "live" + | .draining => "draining" + +instance : FromJson RegistryLifecycle where + fromJson? + | .str "live" => .ok .live + | .str "draining" => .ok .draining + | json => .error s!"expected registry lifecycle 'live' or 'draining', got {json.compress}" + +/-- One statically configured workspace owned by a CLI session. -/ +structure WorkspaceBinding where + workspaceId : WorkspaceId root : String configHash : String leanCmd? : Option String := none plugin? : Option String := none rocqCmd? : Option String := none toolchain? : Option String := none + bundleId? : Option String := none + deriving FromJson, ToJson + +/-- +The private descriptor for one wrapper-owned CLI session. + +The descriptor is deliberately shaped as a session with a nonempty workspace collection even +while the public owner command creates one workspace. This keeps session identity separate from +workspace routing and leaves static multi-workspace ownership as an additive CLI feature. +-/ +structure SessionDescriptor where + schemaVersion : Nat + lifecycle : RegistryLifecycle + daemonId : String + capability : String + pid : Nat + ownerPid : Nat + port? : Option Nat := none + workspaces : Array WorkspaceBinding + /-- Hash of the complete frozen session configuration. -/ + configHash : String clientBin? : Option String := none daemonBin? : Option String := none - bundleId? : Option String := none startedAt : String requestedPort? : Option Nat := none deriving FromJson, ToJson +def SessionDescriptor.rootSummary (entry : SessionDescriptor) : String := + String.intercalate ", " <| entry.workspaces.toList.map (·.root) + +def SessionDescriptor.identity (entry : SessionDescriptor) : DaemonIdentity := { + daemonId := entry.daemonId + configHash := entry.configHash +} + +def SessionDescriptor.redactedJson (entry : SessionDescriptor) : Json := + (toJson entry).setObjVal! "capability" (toJson "") + structure DesiredConfig where root : System.FilePath leanCmd? : Option String := none @@ -48,32 +96,60 @@ structure DesiredConfig where def natToPort? (n : Nat) : Option UInt16 := if n < UInt16.size then some n.toUInt16 else none -def registryEndpoint? (entry : RegistryEntry) : Option Transport.Endpoint := do +def registryEndpoint? (entry : SessionDescriptor) : Option Transport.Endpoint := do (natToPort? =<< entry.port?).map Transport.Endpoint.tcp -def endpointFromEntry (entry : RegistryEntry) : IO Transport.Endpoint := do +def endpointFromEntry (entry : SessionDescriptor) : IO Transport.Endpoint := do match registryEndpoint? entry with | some endpoint => pure endpoint - | none => throw <| IO.userError s!"invalid Beam daemon transport data in registry for {entry.root}" + | none => + let message := + s!"invalid Beam daemon transport data for session {entry.daemonId} ({entry.rootSummary})" + throw (IO.userError message) def endpointSummary (endpoint : Transport.Endpoint) : String := Transport.endpointDescription endpoint -private def statsRoot? (resp : Response) : Option String := do - let result ← resp.result? - result.getObjValAs? String "root" |>.toOption - -def daemonRoot? +private structure DaemonProbe where + root : String + identity? : Option DaemonIdentity + +private def daemonProbeResponseTimeoutMs : Nat := + 2000 + +private def daemonProbeOfResponse (resp : Response) : Except BrokerClientFailure DaemonProbe := do + unless resp.ok do + throw <| .invalidResponse s!"Beam daemon stats probe failed: {(toJson resp).compress}" + let some result := resp.result? + | throw <| .invalidResponse "Beam daemon stats probe omitted its result" + let root ← + match result.getObjValAs? String "root" with + | .ok root => pure root + | .error err => throw <| .invalidResponse s!"invalid Beam daemon stats root: {err}" + let identity? ← + match result.getObjVal? "daemonIdentity" with + | .error _ => pure none + | .ok identityJson => + match fromJson? identityJson with + | .ok identity => pure (some identity) + | .error err => + throw <| .invalidResponse s!"invalid Beam daemon identity: {err}" + pure { root, identity? } + +private def daemonProbe (endpoint : Transport.Endpoint) - (workspaceId : WorkspaceId) : IO (Option String) := do - try - let resp ← sendRequest endpoint { op := .stats, workspaceId? := some workspaceId } - if resp.ok then - pure (statsRoot? resp) - else - pure none - catch _ => - pure none + (workspaceId : WorkspaceId) + (capability? : Option String := none) : IO (Except BrokerClientFailure DaemonProbe) := do + match ← sendRequestWithStreamTimeoutResult endpoint + { op := .stats, workspaceId? := some workspaceId, daemonCapability? := capability? } + daemonProbeResponseTimeoutMs (fun _ => pure ()) with + | .ok resp => pure <| daemonProbeOfResponse resp + | .error failure => pure <| .error failure + +def daemonRootResult + (endpoint : Transport.Endpoint) + (workspaceId : WorkspaceId) : IO (Except BrokerClientFailure String) := do + pure <| (← daemonProbe endpoint workspaceId).map (·.root) def endpointOccupancyError (endpoint : Transport.Endpoint) @@ -83,9 +159,18 @@ def endpointOccupancyError def endpointInUseError (endpoint : Transport.Endpoint) : String := s!"selected endpoint {endpointSummary endpoint} is already in use" -def startupFailureSuggestsEndpointInUse (message : String) : Bool := - message.contains "address already in use" || - message.contains "Address already in use" +def endpointGenerationMismatchError + (endpoint : Transport.Endpoint) + (daemonRoot : System.FilePath) : String := + s!"selected endpoint {endpointSummary endpoint} already serves Beam root {daemonRoot} " ++ + "with another daemon generation" + +def endpointProtocolError (endpoint : Transport.Endpoint) (detail : String) : String := + s!"selected endpoint {endpointSummary endpoint} did not return a valid Beam daemon response: {detail}" + +def startupLogSuggestsEndpointInUse (logText : String) : Bool := + logText.contains "address already in use" || + logText.contains "Address already in use" def shouldRetryAutomaticStartup (usesAutomaticEndpoint : Bool) @@ -93,16 +178,6 @@ def shouldRetryAutomaticStartup (endpointOccupied startupAddressInUse : Bool) : Bool := usesAutomaticEndpoint && tries > 0 && (endpointOccupied || startupAddressInUse) --- A listening TCP port is not enough evidence that it belongs to this project: --- random auto-port selection can collide with an unrelated Beam daemon. -def daemonServesRoot - (endpoint : Transport.Endpoint) - (workspaceId : WorkspaceId) - (root : System.FilePath) : IO Bool := do - match ← daemonRoot? endpoint workspaceId with - | some daemonRoot => Beam.sameFilePath (System.FilePath.mk daemonRoot) root - | none => pure false - def endpointAcceptsConnection (endpoint : Transport.Endpoint) : IO Bool := do try let conn ← Transport.connect endpoint @@ -111,4 +186,37 @@ def endpointAcceptsConnection (endpoint : Transport.Endpoint) : IO Bool := do catch _ => pure false +inductive DaemonGenerationStatus where + | unavailable + | unrecognized (failure : BrokerClientFailure) + | wrongRoot (daemonRoot : String) + | wrongGeneration (daemonRoot : String) + | exact + deriving Repr + +/-- Classify one endpoint observation against the expected root and wrapper daemon generation. -/ +def daemonGenerationStatus + (endpoint : Transport.Endpoint) + (workspaceId : WorkspaceId) + (root : System.FilePath) + (identity : DaemonIdentity) + (capability : String) : IO DaemonGenerationStatus := do + match ← daemonProbe endpoint workspaceId (some capability) with + | .error failure => + match failure with + | .transport _ _ => + if ← endpointAcceptsConnection endpoint then + pure <| .unrecognized failure + else + pure .unavailable + | .invalidResponse _ | .streamCallback _ | .responseTimeout _ => + pure <| .unrecognized failure + | .ok probe => + unless ← Beam.sameFilePath (System.FilePath.mk probe.root) root do + return .wrongRoot probe.root + if probe.identity? == some identity then + pure .exact + else + pure <| .wrongGeneration probe.root + end Beam.Daemon diff --git a/Beam/Daemon/Registry.lean b/Beam/Daemon/Registry.lean new file mode 100644 index 00000000..3c6eb9d7 --- /dev/null +++ b/Beam/Daemon/Registry.lean @@ -0,0 +1,102 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: Emilio J. Gallego Arias +-/ + +import Lean +import Beam.Daemon.Paths +import Beam.Daemon.Protocol + +open Lean + +namespace Beam.Daemon + +/-- Typed result of reading the versioned on-disk daemon registry. -/ +inductive RegistryRead where + | absent + | legacy + | unsupported (schemaVersion : Nat) + | malformed (detail : String) + | current (entry : SessionDescriptor) + +def RegistryRead.entry? : RegistryRead → Option SessionDescriptor + | .current entry => some entry + | .absent | .legacy | .unsupported _ | .malformed _ => none + +def RegistryRead.status : RegistryRead → String + | .absent => "absent" + | .legacy => "legacy" + | .unsupported _ => "unsupported" + | .malformed _ => "malformed" + | .current _ => "current" + +def RegistryRead.detail? : RegistryRead → Option String + | .legacy => some "legacy registry has no schemaVersion" + | .unsupported version => some s!"unsupported registry schemaVersion {version}" + | .malformed detail => some detail + | .absent | .current _ => none + +private def validateWorkspaceBindings (workspaces : Array WorkspaceBinding) : Except String Unit := do + if workspaces.isEmpty then + throw "session descriptor must contain at least one workspace" + let mut ids : Std.TreeSet String compare := {} + let mut roots : Std.TreeSet String compare := {} + for workspace in workspaces do + if workspace.workspaceId.isEmpty then + throw "session workspace id must not be empty" + if workspace.root.isEmpty then + throw s!"session workspace '{workspace.workspaceId}' has an empty root" + let rootPath := System.FilePath.mk workspace.root + unless rootPath.isAbsolute do + throw s!"session workspace '{workspace.workspaceId}' root is not absolute" + if workspace.configHash.isEmpty then + throw s!"session workspace '{workspace.workspaceId}' has an empty configuration hash" + if ids.contains workspace.workspaceId then + throw s!"duplicate session workspace id '{workspace.workspaceId}'" + let normalizedRoot := rootPath.normalize.toString + if roots.contains normalizedRoot then + throw s!"duplicate session workspace root '{workspace.root}'" + ids := ids.insert workspace.workspaceId + roots := roots.insert normalizedRoot + +private def validateSessionDescriptor (entry : SessionDescriptor) : Except String Unit := do + if entry.daemonId.isEmpty then + throw "session descriptor daemonId must not be empty" + if entry.capability.isEmpty then + throw "session descriptor capability must not be empty" + if entry.configHash.isEmpty then + throw "session descriptor configuration hash must not be empty" + validateWorkspaceBindings entry.workspaces + +def readRegistryAt (path : System.FilePath) : IO RegistryRead := do + unless ← path.pathExists do + return .absent + try + let text ← IO.FS.readFile path + let json ← + match Json.parse text with + | .ok json => pure json + | .error err => return .malformed s!"invalid registry JSON: {err}" + match json.getObjVal? "schemaVersion" with + | .error _ => pure .legacy + | .ok schemaJson => + let schemaVersion ← + match fromJson? (α := Nat) schemaJson with + | .ok schemaVersion => pure schemaVersion + | .error err => return .malformed s!"invalid registry schemaVersion: {err}" + unless schemaVersion == registrySchemaVersion do + return .unsupported schemaVersion + match fromJson? json with + | .ok entry => + match validateSessionDescriptor entry with + | .ok () => pure <| .current entry + | .error err => pure <| .malformed s!"invalid registry schema: {err}" + | .error err => pure <| .malformed s!"invalid registry schema: {err}" + catch err => + pure <| .malformed s!"could not read registry: {err}" + +def readRegistry (root : System.FilePath) : IO RegistryRead := do + readRegistryAt (← registryPath root) + +end Beam.Daemon diff --git a/Beam/Feedback.lean b/Beam/Feedback.lean index 2c9931be..45ed8f71 100644 --- a/Beam/Feedback.lean +++ b/Beam/Feedback.lean @@ -460,7 +460,6 @@ private def runtimeSummarySection (collection : Collection) : String := optionalLine "runtime current" ((jsonBoolField? identity "runtime_current").map boolText) ++ optionalLine "runtime error" (jsonStringField? identity "runtime_error") ++ optionalLine "source" source? ++ - optionalLine "daemon registry pid" (jsonStringField? daemon "registryPidStatus") ++ optionalLine "daemon endpoint" (jsonStringField? daemon "registryEndpoint") ++ warningLines mdSection "Beam Runtime" (String.intercalate "\n" lines) diff --git a/Beam/Mcp/Server.lean b/Beam/Mcp/Server.lean index 12b883a0..88bcb5c3 100644 --- a/Beam/Mcp/Server.lean +++ b/Beam/Mcp/Server.lean @@ -44,33 +44,32 @@ inductive ProtocolState where | modern deriving Repr -structure ApplicationState where - workspaces : Std.TreeMap Beam.Broker.WorkspaceId System.FilePath := {} - runtime? : Option Beam.Broker.ServerRuntime := none - structure ServerState where protocol : Std.Mutex ProtocolState - application : IO.Ref ApplicationState + private runtime : Std.Mutex (Option Beam.Broker.ServerRuntime) def ServerState.create : IO ServerState := do pure { protocol := ← Std.Mutex.new .undecided - application := ← IO.mkRef {} + runtime := ← Std.Mutex.new none } def ServerState.protocolState (state : ServerState) : IO ProtocolState := state.protocol.atomically get -def ServerState.applicationState (state : ServerState) : IO ApplicationState := - state.application.get +private def ServerState.runtime? (state : ServerState) : IO (Option Beam.Broker.ServerRuntime) := + state.runtime.atomically get -private def ApplicationState.trackWorkspace - (state : ApplicationState) - (workspaceId : Beam.Broker.WorkspaceId) - (root : System.FilePath) : ApplicationState := { - state with - workspaces := state.workspaces.insert workspaceId root -} +/-- Close and forget the in-process broker runtime owned by this MCP server state. -/ +def ServerState.closeRuntime (state : ServerState) : IO Unit := + state.runtime.atomically do + match ← get with + | none => pure () + | some runtime => + -- Transfer ownership out of the state before waiting for broker teardown. A concurrent + -- creator remains excluded by the same mutex, and repeated close calls are idempotent. + set (none : Option Beam.Broker.ServerRuntime) + runtime.close structure NotificationSink where send : Json → IO Unit := fun _ => pure () @@ -318,9 +317,11 @@ private def RequestStatusEmitter.emitOnce Std.Internal.UV.Timer.stop emitter.timer private def RequestStatusEmitter.finish (emitter : RequestStatusEmitter) : IO Unit := do - emitter.state.atomically do - modify fun current => { current with finished := true } - Std.Internal.UV.Timer.stop emitter.timer + try + emitter.state.atomically do + modify fun current => { current with finished := true } + finally + Std.Internal.UV.Timer.stop emitter.timer private def RequestStatusEmitter.create (notifier : Notifier) @@ -329,19 +330,26 @@ private def RequestStatusEmitter.create (path? : Option String) : IO RequestStatusEmitter := do let delayMs ← requestStatusDelayMs let timer ← Std.Internal.UV.Timer.mk delayMs.toUInt64 false - let emitter : RequestStatusEmitter := { - requestId := requestId.json - tool - path? - state := ← Std.Mutex.new {} - timer - emitStatus := emitToolStatusLog notifier - } - let timerResult ← timer.next - IO.chainTask timerResult.result? fun - | some () => emitter.emitOnce .running s!"{toolTarget tool path?} is still working." - | none => pure () - pure emitter + try + let emitter : RequestStatusEmitter := { + requestId := requestId.json + tool + path? + state := ← Std.Mutex.new {} + timer + emitStatus := emitToolStatusLog notifier + } + let timerResult ← timer.next + IO.chainTask timerResult.result? fun + | some () => emitter.emitOnce .running s!"{toolTarget tool path?} is still working." + | none => pure () + pure emitter + catch err => + try + Std.Internal.UV.Timer.stop timer + catch _ => + pure () + throw err private def toolPath? (arguments : Json) : Option String := (arguments.getObjValAs? String "path").toOption @@ -468,13 +476,11 @@ private def createRuntimeForRoot | .error err => pure <| .error err private def ensureBrokerWorkspace - (state : ServerState) (opts : Options) (runtime : Beam.Broker.ServerRuntime) (workspaceId : Beam.Broker.WorkspaceId) (root : System.FilePath) : IO (Except RpcError System.FilePath) := do - let application ← state.applicationState - match application.workspaces.get? workspaceId with + match ← runtime.workspaceRoot? workspaceId with | some trackedRoot => if trackedRoot == root then pure <| .ok root @@ -485,36 +491,27 @@ private def ensureBrokerWorkspace match ← Runtime.mkBrokerConfig (runtimeOptions opts) root with | .error err => pure <| .error err | .ok config => - let brokerResp ← runtime.initWorkspaceWithConfig workspaceId config (some .set) - match brokerResp with - | .successResult .. => - state.application.modify fun application => - application.trackWorkspace workspaceId config.root - pure <| .ok config.root - | .errorResult failure => + match ← runtime.initWorkspaceWithConfig workspaceId config (some .set) with + | .ok initialized => pure <| .ok initialized.root + | .error failure => pure <| .error <| RpcError.invalidRequest failure.error.message private def ensureRuntimeForWorkspace (state : ServerState) (opts : Options) - (setupMutex : Std.Mutex Unit) (workspaceId : Beam.Broker.WorkspaceId) (root : System.FilePath) : IO (Except RpcError (Beam.Broker.ServerRuntime × System.FilePath)) := do - setupMutex.atomically do - let application ← state.applicationState - match application.runtime? with + state.runtime.atomically do + match ← get with | some runtime => - match ← ensureBrokerWorkspace state opts runtime workspaceId root with + match ← ensureBrokerWorkspace opts runtime workspaceId root with | .ok canonicalRoot => pure <| .ok (runtime, canonicalRoot) | .error err => pure <| .error err | none => match ← createRuntimeForRoot opts workspaceId root with | .error err => pure <| .error err | .ok (runtime, canonicalRoot) => - state.application.modify fun application => { - application.trackWorkspace workspaceId canonicalRoot with - runtime? := some runtime - } + set (some runtime) pure <| .ok (runtime, canonicalRoot) private def workspaceErrorToToolError (err : Beam.Workspace.RootError) : ToolError := @@ -572,14 +569,14 @@ private def decodeBeamStatsResult (json : Json) : Except String BeamStatsResult private def handleBeamStats (state : ServerState) : IO Json := do let runtime ← - match (← state.applicationState).runtime? with + match ← state.runtime? with | some runtime => pure runtime | none => return callToolResult <| Json.mkObj [ ("uptimeMs", toJson (0 : Nat)), ("workspaces", Json.mkObj []) ] - let (brokerResp, _) ← runtime.dispatchRequest { op := .stats } + let brokerResp ← runtime.dispatchRequest { op := .stats } match normalizeBrokerResponse .beamStats brokerResp with | .error err => pure <| callToolErrorResult err @@ -596,14 +593,8 @@ private def handleBeamStats pure <| callToolResult result private def handleDropWorkspace - (state : ServerState) + (runtime? : Option Beam.Broker.ServerRuntime) (workspace : ResolvedWorkspace) : IO Json := do - let application ← state.applicationState - let updateTrackedState : IO Unit := - state.application.modify fun application => { - application with - workspaces := application.workspaces.erase workspace.workspaceId - } let resultJson (dropped invalidatedHandles : Bool) (reason? : Option String := none) : Json := Json.mkObj <| [ ("workspace", toJson workspace.descriptor), @@ -612,26 +603,15 @@ private def handleDropWorkspace ] ++ match reason? with | some reason => [("reason", toJson reason)] | none => [] - match application.runtime? with + match runtime? with | none => pure <| callToolResult <| resultJson false false (some "notFound") | some runtime => - let (brokerResp, _) ← runtime.dispatchRequest { - op := .dropWorkspace - workspaceId? := some workspace.workspaceId - } - match brokerResp with - | .successResult payload .. => - match fromJson? (α := Beam.Workspace.DropResult) payload with - | .ok dropped => - if dropped.dropped then - updateTrackedState - pure <| callToolResult <| - resultJson dropped.dropped dropped.invalidatedHandles dropped.reason? - | .error err => - pure <| callToolErrorResult <| ToolError.invalidResult - s!"invalid workspace drop result: {err}" - | .errorResult failure => + match ← runtime.dropWorkspace workspace.workspaceId with + | .ok dropped => + pure <| callToolResult <| + resultJson dropped.dropped dropped.invalidatedHandles dropped.reason? + | .error failure => pure <| callToolErrorResult <| ToolError.fromBrokerError failure.error private def resolvedBeamHome? : IO (Option System.FilePath) := do @@ -664,8 +644,7 @@ def Internal.serverVersionText (opts : Options) : IO String := do private def handleBeamVersion (state : ServerState) (opts : Options) : IO Json := do - let application ← state.applicationState - let identity ← serverIdentity opts none (some application.runtime?.isSome) + let identity ← serverIdentity opts none (some (← state.runtime?).isSome) pure <| callToolResult identity.asJson private def collectFeedbackRuntimePayload @@ -677,13 +656,13 @@ private def collectFeedbackRuntimePayload | none => pure (Json.null, Json.null, warnings.push "no active MCP Lean runtime was available for stats/open-files") | some runtime => - let (statsResp, _) ← runtime.dispatchRequest { + let statsResp ← runtime.dispatchRequest { op := .stats workspaceId? := some workspaceId root? := some root.toString } let (stats, warnings) := Beam.Feedback.responsePayloadOrWarning "stats" statsResp warnings - let (openResp, _) ← runtime.dispatchRequest { + let openResp ← runtime.dispatchRequest { op := .openDocs workspaceId? := some workspaceId root? := some root.toString @@ -752,9 +731,8 @@ private def handleBeamFeedback else do let identity ← serverIdentity opts (some root) (some runtime?.isSome) let daemon ← Beam.Daemon.daemonDebugContextJson root - let warnings := Beam.Daemon.daemonDebugWarnings daemon let (stats, openDocs, warnings') ← - collectFeedbackRuntimePayload runtime? workspaceId root warnings + collectFeedbackRuntimePayload runtime? workspaceId root #[] pure { generatedAt activeRoot? := some root.toString @@ -800,7 +778,6 @@ private def brokerRequestForTool def Internal.handleToolCall (state : ServerState) (opts : Options) - (setupMutex : Std.Mutex Unit) (brokerClientRequestId : String) (beforeDispatch : Beam.Broker.RequestHandle → IO Bool) (req : Request) @@ -827,8 +804,8 @@ def Internal.handleToolCall | .error err => return .ok <| callToolErrorResult err if initialProgress == 0 then emitProgress? progress? s!"{params.name.key}: preparing workspace eviction" - let result ← setupMutex.atomically do - handleDropWorkspace state workspace + let result ← state.runtime.atomically do + handleDropWorkspace (← get) workspace Internal.traceMcp s!"tools/call workspace drop complete id={req.id.label} tool={params.name.key}" return .ok result @@ -850,12 +827,14 @@ def Internal.handleToolCall if params.name == .beamFeedbackReport then let reporter ← CallReporter.create notifier req.id params progress? try - let application ← state.applicationState - let selectedRuntime? := - if application.workspaces.get? workspace.workspaceId == some workspace.root then - application.runtime? - else - none + let selectedRuntime? ← + match ← state.runtime? with + | none => pure none + | some runtime => + if (← runtime.workspaceRoot? workspace.workspaceId) == some workspace.root then + pure (some runtime) + else + pure none let result ← handleBeamFeedback opts workspace.descriptor workspace.workspaceId workspace.root selectedRuntime? params.arguments progress? Internal.traceMcp s!"tools/call feedback complete id={req.id.label} tool={params.name.key}" @@ -869,10 +848,10 @@ def Internal.handleToolCall Internal.traceMcp s!"tools/call invalid input id={req.id.label} tool={params.name.key} error={err}" return .ok <| callToolErrorResult <| ToolError.invalidInput err let reporter ← CallReporter.create notifier req.id params progress? - reporter.emitPreparing try + reporter.emitPreparing let (runtime, root) ← - match ← ensureRuntimeForWorkspace state opts setupMutex workspace.workspaceId workspace.root with + match ← ensureRuntimeForWorkspace state opts workspace.workspaceId workspace.root with | .ok runtimeAndRoot => Internal.traceMcp s!"tools/call runtime ready id={req.id.label} tool={params.name.key}" pure runtimeAndRoot @@ -886,7 +865,7 @@ def Internal.handleToolCall let emitBrokerProgress? : Option (Beam.Broker.SyncFileProgress → IO Unit) := reporter.progress?.map fun _ => reporter.emitFileProgress Internal.traceMcp s!"tools/call dispatch broker id={req.id.label} tool={params.name.key}" - let (brokerResp, _) ← runtime.dispatchRequestWithHandle brokerReq beforeDispatch + let brokerResp ← runtime.dispatchRequestWithHandle brokerReq beforeDispatch (emitProgress? := emitBrokerProgress?) (emitDiagnostic? := some emitDiagnostic) Internal.traceMcp @@ -905,7 +884,6 @@ def Internal.handleToolCall private def handleReadyOperationRequest (state : ServerState) (opts : Options) - (setupMutex : Std.Mutex Unit) (brokerClientRequestId : String) (req : Request) (admitted : AdmittedRequestContext) @@ -929,7 +907,7 @@ private def handleReadyOperationRequest pure <| successResponseForEra era req.id result | "tools/call" => let parsedParams := parseCallToolParams req.params? - match ← Internal.handleToolCall state opts setupMutex brokerClientRequestId + match ← Internal.handleToolCall state opts brokerClientRequestId (fun _ => pure true) req admitted parsedParams notifications with | .ok result => pure <| successResponseForEra era req.id result | .error err => pure <| errorResponse req.id err @@ -1062,7 +1040,6 @@ def Internal.handleRequestForProtocol (req : Request) (evidence : RequestProtocolEvidence) (notifications : NotificationSink := {}) : IO Json := do - let setupMutex ← Std.Mutex.new () let brokerClientRequestId := s!"mcp:sync:{req.id.label}" let era ← match ← Internal.admitCompatibleRequest state evidence with @@ -1106,7 +1083,7 @@ def Internal.handleRequestForProtocol | .error err => pure <| errorResponse req.id err | .ok admitted => handleReadyOperationRequest - state opts setupMutex brokerClientRequestId req admitted notifications + state opts brokerClientRequestId req admitted notifications | method => match era with | .modern _ => diff --git a/Beam/Mcp/Stdio.lean b/Beam/Mcp/Stdio.lean index 7e81a3b2..9b4a3ba4 100644 --- a/Beam/Mcp/Stdio.lean +++ b/Beam/Mcp/Stdio.lean @@ -10,9 +10,10 @@ open Lean namespace Beam.Mcp.Stdio -def isBrokenPipeError (err : IO.Error) : Bool := - let msg := err.toString - msg.contains "broken pipe" || msg.contains "Broken pipe" || msg.contains "EPIPE" +/-- Whether writing failed because the output resource has disappeared, including POSIX `EPIPE`. -/ +def isClosedOutputError : IO.Error → Bool + | .resourceVanished _ _ => true + | _ => false def stripLineEnding (line : String) : String := let line := @@ -25,16 +26,8 @@ def stripLineEnding (line : String) : String := else line -def writeJsonLineToStream (stream : IO.FS.Stream) (json : Json) : IO Unit := do - stream.putStr (json.compress ++ "\n") - stream.flush - def writeJsonLineToHandle (handle : IO.FS.Handle) (json : Json) : IO Unit := do handle.putStr (json.compress ++ "\n") handle.flush -def writeStdoutJsonLine (json : Json) : IO Unit := do - let stdout ← IO.getStdout - writeJsonLineToStream stdout json - end Beam.Mcp.Stdio diff --git a/Beam/Mcp/StdioServer.lean b/Beam/Mcp/StdioServer.lean index b212db80..e38d5f9c 100644 --- a/Beam/Mcp/StdioServer.lean +++ b/Beam/Mcp/StdioServer.lean @@ -64,9 +64,16 @@ private structure OutputSink where private def OutputSink.create : BaseIO OutputSink := do pure { mutex := ← Std.Mutex.new () } -private def OutputSink.send (sink : OutputSink) (json : Json) : IO Unit := do +private def OutputSink.sendWhen + (sink : OutputSink) + (condition : IO Bool) + (json : Json) : IO Unit := do sink.mutex.atomically do - writeJsonLine json + if ← condition then + writeJsonLine json + +private def OutputSink.send (sink : OutputSink) (json : Json) : IO Unit := do + sink.sendWhen (pure true) json private inductive RequestPhase where | active @@ -92,7 +99,10 @@ private structure InFlightRequest where private structure RoutingState where nextBrokerId : Nat := 1 + -- `inFlight` is the client-ID routing surface. `admitted` retains exact generations through + -- terminal output so EOF shutdown and control fences can still await them after ID retirement. inFlight : Std.TreeMap RequestId InFlightRequest := {} + admitted : Std.TreeMap String InFlightRequest := {} controlBarrier? : Option (IO.Promise Unit) := none closing : Bool := false @@ -103,22 +113,21 @@ private inductive RequestRegistrationError where /- Nested coordinator locks flow in one direction: -* setup → progress → request → output during workspace cache eviction -* progress → request → output for request notifications -* request → output for active request messages +* progress → output → request for request notifications +* output → request for active request messages -Routing is released before setup, request, or output is acquired. Output acquires no coordinator lock. +Routing is released before runtime control, request, or output is acquired. Runtime control is owned +by `ServerState` and does not acquire coordinator locks. Request state is inspected only after output +serialization is available, so cancellation never waits for a blocked stdout write. -/ private structure Coordinator where state : ServerState - setupMutex : Std.Mutex Unit routing : Std.Mutex RoutingState output : OutputSink private def Coordinator.create : IO Coordinator := do pure { state := ← ServerState.create - setupMutex := ← Std.Mutex.new () routing := ← Std.Mutex.new {} output := ← OutputSink.create } @@ -148,10 +157,11 @@ private def Coordinator.registerRequest routing with nextBrokerId := routing.nextBrokerId + 1 inFlight := routing.inFlight.insert id request + admitted := routing.admitted.insert brokerId request } pure <| .ok request -private def Coordinator.eraseRequest +private def Coordinator.retireRequestId (coordinator : Coordinator) (request : InFlightRequest) : IO Unit := do coordinator.routing.atomically do @@ -164,6 +174,13 @@ private def Coordinator.eraseRequest routing | none => routing +private def Coordinator.completeRequest + (coordinator : Coordinator) + (request : InFlightRequest) : IO Unit := do + coordinator.routing.atomically do + modify fun routing => + { routing with admitted := routing.admitted.erase request.brokerId } + private def InFlightRequest.resolveDone (request : InFlightRequest) : IO Unit := do try request.done.resolve () @@ -190,14 +207,22 @@ private def Coordinator.currentControlBarrier? coordinator.routing.atomically do pure (← get).controlBarrier? +private structure ControlRequestFence where + previous? : Option (IO.Promise Unit) + done : IO.Promise Unit + priorRequests : Array InFlightRequest + private def Coordinator.pushControlBarrier - (coordinator : Coordinator) : IO (Option (IO.Promise Unit) × IO.Promise Unit) := do + (coordinator : Coordinator) + (request : InFlightRequest) : IO ControlRequestFence := do let done ← IO.Promise.new - let previous? ← coordinator.routing.atomically do + let (previous?, priorRequests) ← coordinator.routing.atomically do let routing ← get + let priorRequests := routing.admitted.toList.filterMap (fun (_, other) => + if other.brokerId == request.brokerId then none else some other) |>.toArray set { routing with controlBarrier? := some done } - pure routing.controlBarrier? - pure (previous?, done) + pure (routing.controlBarrier?, priorRequests) + pure { previous?, done, priorRequests } private def awaitControlBarrier (barrier? : Option (IO.Promise Unit)) : IO Unit := do match barrier? with @@ -208,9 +233,7 @@ private def InFlightRequest.sendIfActive (request : InFlightRequest) (output : OutputSink) (json : Json) : IO Unit := do - request.state.atomically do - if (← get).phase == .active then - output.send json + output.sendWhen request.isActive json private def InFlightRequest.bindBrokerRequest (request : InFlightRequest) @@ -239,11 +262,85 @@ private def Coordinator.finishRequest pure false | .completed => pure false + -- Retire the exact admission before its terminal response becomes visible. A client may reuse + -- an ID as soon as it observes that response; retaining the routing entry until after the write + -- creates a race in which the new request is mistaken for a duplicate active request. + coordinator.retireRequestId request if sendResponse then coordinator.output.send response finally - coordinator.eraseRequest request - request.resolveDone + -- Retry retirement when an earlier state/routing operation failed, and make every cleanup step + -- independent. Barriers observe completion only after the terminal write has finished. + try + coordinator.retireRequestId request + finally + try + coordinator.completeRequest request + finally + request.resolveDone + +private def traceMcpSafely (message : String) : IO Unit := do + try + Internal.traceMcp message + catch _ => + pure () + +/-- +Own one registered request through terminal completion. + +`after` belongs to the same ownership scope and runs after the request's terminal write attempt. In +particular, a workspace-control fence is not released before the request is retired and its `done` +promise is resolved. +-/ +private def Coordinator.runOwnedRequest + (coordinator : Coordinator) + (req : Request) + (request : InFlightRequest) + (work : IO Json) + (after : IO Unit := pure ()) : IO Unit := do + try + let response ← + try + work + catch e => + traceMcpSafely s!"request work failed id={req.id.label}: {e.toString}" + pure <| errorResponse req.id (RpcError.internalError e.toString) + try + coordinator.finishRequest request response + catch e => + if !Beam.Mcp.Stdio.isClosedOutputError e then + traceMcpSafely s!"request completion failed id={req.id.label}: {e.toString}" + pure () + finally + try + after + catch e => + traceMcpSafely s!"request finalizer failed id={req.id.label}: {e.toString}" + +/-- Project a worker or setup failure through the registered request's single completion path. -/ +private def Coordinator.failOwnedRequest + (coordinator : Coordinator) + (req : Request) + (request : InFlightRequest) + (error : IO.Error) + (after : IO Unit := pure ()) : IO Unit := do + traceMcpSafely s!"request work failed id={req.id.label}: {error.toString}" + coordinator.runOwnedRequest req request + (pure <| errorResponse req.id (RpcError.internalError error.toString)) after + +/-- Transfer a registered request to a worker, or complete it synchronously if task startup fails. -/ +private def Coordinator.spawnOwnedRequest + (coordinator : Coordinator) + (req : Request) + (request : InFlightRequest) + (work : IO Json) + (after : IO Unit := pure ()) : IO Unit := do + try + let _ ← IO.asTask (prio := Task.Priority.dedicated) do + coordinator.runOwnedRequest req request work after + pure () + catch e => + coordinator.failOwnedRequest req request e after private def InFlightRequest.markClientCancelled (request : InFlightRequest) : IO (Bool × Option Beam.Broker.RequestHandle) := do @@ -258,6 +355,19 @@ private def InFlightRequest.markClientCancelled | .clientCancelled | .completed => pure (false, none) +private def InFlightRequest.cancel (request : InFlightRequest) : IO Unit := do + let (cancelled, brokerRequest?) ← request.markClientCancelled + if cancelled then + match brokerRequest? with + | none => pure () + | some brokerRequest => + let _ ← IO.asTask (prio := Task.Priority.dedicated) do + try + discard <| brokerRequest.cancel + catch e => + Internal.traceMcp s!"broker cancellation failed id={request.id.label}: {e.toString}" + pure () + private def Coordinator.cancelRequest (coordinator : Coordinator) (id : RequestId) : IO Unit := do @@ -265,57 +375,32 @@ private def Coordinator.cancelRequest pure <| (← get).inFlight.get? id match request? with | none => pure () - | some request => - let (cancelled, brokerRequest?) ← request.markClientCancelled - if cancelled then - match brokerRequest? with - | none => pure () - | some brokerRequest => - let _ ← IO.asTask (prio := Task.Priority.dedicated) do - try - discard <| brokerRequest.cancel - catch e => - Internal.traceMcp s!"broker cancellation failed id={id.label}: {e.toString}" - pure () + | some request => request.cancel private def Coordinator.beginClosing - (coordinator : Coordinator) : IO (Bool × Array InFlightRequest) := do - let (alreadyClosing, requests) ← coordinator.routing.atomically do + (coordinator : Coordinator) : IO (Array InFlightRequest) := do + let requests ← coordinator.routing.atomically do let routing ← get - let requests := routing.inFlight.toList.map Prod.snd |>.toArray + let requests := routing.admitted.toList.map Prod.snd |>.toArray set { routing with closing := true } - pure (routing.closing, requests) + pure requests for request in requests do - coordinator.cancelRequest request.id - pure (alreadyClosing, requests) + request.cancel + pure requests private def awaitRequestDone (request : InFlightRequest) : IO Unit := do awaitPromise s!"in-flight request {request.id.label}" request.done -private def Coordinator.awaitRequests - (_coordinator : Coordinator) - (requests : Array InFlightRequest) : IO Unit := do +private def awaitRequests (requests : Array InFlightRequest) : IO Unit := do for request in requests do awaitRequestDone request -private def Coordinator.otherInFlightRequests - (coordinator : Coordinator) - (request : InFlightRequest) : IO (Array InFlightRequest) := do - coordinator.routing.atomically do - pure <| (← get).inFlight.toList.filterMap (fun (_, other) => - if other.brokerId == request.brokerId then none else some other) |>.toArray - private def Coordinator.closeTransport (coordinator : Coordinator) : IO Unit := do - let (alreadyClosing, requests) ← - coordinator.beginClosing - coordinator.awaitRequests requests - unless alreadyClosing do - coordinator.setupMutex.atomically do - let application ← coordinator.state.applicationState - match application.runtime? with - | none => pure () - | some runtime => - discard <| runtime.dispatchRequest { op := .shutdown } + try + let requests ← coordinator.beginClosing + awaitRequests requests + finally + coordinator.state.closeRuntime private def Coordinator.admitToolRequest (coordinator : Coordinator) @@ -336,24 +421,20 @@ private def Coordinator.executeToolRequest let notifications : NotificationSink := { send := fun json => request.sendIfActive coordinator.output json } - try - match ← Internal.handleToolCall - coordinator.state - opts - coordinator.setupMutex - request.brokerId - request.bindBrokerRequest - req - admitted - parsedParams - notifications - initialProgress with - | .ok result => pure <| successResponseForEra admitted.era req.id result - | .error err => pure <| errorResponse req.id err - catch e => - pure <| errorResponse req.id (RpcError.internalError e.toString) - -private def Coordinator.runToolRequest + match ← Internal.handleToolCall + coordinator.state + opts + request.brokerId + request.bindBrokerRequest + req + admitted + parsedParams + notifications + initialProgress with + | .ok result => pure <| successResponseForEra admitted.era req.id result + | .error err => pure <| errorResponse req.id err + +private def Coordinator.toolRequestResponse (coordinator : Coordinator) (opts : Options) (req : Request) @@ -361,23 +442,21 @@ private def Coordinator.runToolRequest (parsedParams : Except String CallToolParams) (request : InFlightRequest) (barrier? : Option (IO.Promise Unit)) - (initialProgress : Nat := 0) - (beforeFinish : IO Unit := pure ()) : IO Unit := do - let response ← - try - awaitControlBarrier barrier? - if ← request.isActive then - coordinator.executeToolRequest opts req admitted parsedParams request initialProgress - else - pure <| errorResponse req.id <| - RpcError.invalidRequest "request was cancelled before execution" - catch e => - pure <| errorResponse req.id (RpcError.internalError e.toString) + (initialProgress : Nat := 0) : IO Json := do + awaitControlBarrier barrier? + if ← request.isActive then + coordinator.executeToolRequest opts req admitted parsedParams request initialProgress + else + pure <| errorResponse req.id <| + RpcError.invalidRequest "request was cancelled before execution" + +private def finishReporterSafely + (req : Request) + (finishReporter : IO Unit) : IO Unit := do try - beforeFinish + finishReporter catch e => - Internal.traceMcp s!"request reporter finish failed id={req.id.label}: {e.toString}" - coordinator.finishRequest request response + traceMcpSafely s!"request reporter finish failed id={req.id.label}: {e.toString}" private def Coordinator.spawnToolRequest (coordinator : Coordinator) @@ -386,20 +465,26 @@ private def Coordinator.spawnToolRequest (evidence : RequestProtocolEvidence) (parsedParams : Except String CallToolParams) (request : InFlightRequest) : IO Unit := do + let admission ← + try + coordinator.admitToolRequest req evidence + catch e => + coordinator.failOwnedRequest req request e + return let admitted ← - match ← coordinator.admitToolRequest req evidence with + match admission with | .ok admitted => pure admitted | .error response => - coordinator.finishRequest request response + coordinator.runOwnedRequest req request (pure response) return - let barrier? ← coordinator.currentControlBarrier? - let _ ← IO.asTask (prio := Task.Priority.dedicated) do + let barrier? ← try - coordinator.runToolRequest opts req admitted parsedParams request barrier? + coordinator.currentControlBarrier? catch e => - if !Beam.Mcp.Stdio.isBrokenPipeError e then - Internal.traceMcp s!"request completion failed id={req.id.label}: {e.toString}" - pure () + coordinator.failOwnedRequest req request e + return + coordinator.spawnOwnedRequest req request <| + coordinator.toolRequestResponse opts req admitted parsedParams request barrier? private def Coordinator.handleControlToolRequest (coordinator : Coordinator) @@ -408,42 +493,45 @@ private def Coordinator.handleControlToolRequest (evidence : RequestProtocolEvidence) (parsedParams : Except String CallToolParams) (request : InFlightRequest) : IO Unit := do - match ← coordinator.admitToolRequest req evidence with - | .error response => coordinator.finishRequest request response + let admission ← + try + coordinator.admitToolRequest req evidence + catch e => + coordinator.failOwnedRequest req request e + return + match admission with + | .error response => coordinator.runOwnedRequest req request (pure response) | .ok admitted => - let notifications : NotificationSink := { - send := fun json => request.sendIfActive coordinator.output json - } - let reporter? ← - match parsedParams with - | .ok params => - Internal.createPreDispatchReporter? - coordinator.state req.id admitted params notifications - | .error _ => pure none - let initialProgress := reporter?.map (·.initialProgress) |>.getD 0 - let finishReporter : IO Unit := - match reporter? with - | some reporter => reporter.finish - | none => pure () - let (previous?, done) ← coordinator.pushControlBarrier - let priorRequests ← coordinator.otherInFlightRequests request - let _ ← IO.asTask (prio := Task.Priority.dedicated) do + let fence ← try + coordinator.pushControlBarrier request + catch e => + coordinator.failOwnedRequest req request e + return + let work : IO Json := do + let notifications : NotificationSink := { + send := fun json => request.sendIfActive coordinator.output json + } + let reporter? ← + match parsedParams with + | .ok params => + Internal.createPreDispatchReporter? + coordinator.state req.id admitted params notifications + | .error _ => pure none + let run (initialProgress : Nat) : IO Json := do -- A control operation is a full stream-order fence: work admitted before it drains, -- while work admitted afterward waits on `done`. - coordinator.awaitRequests priorRequests - coordinator.runToolRequest opts req admitted parsedParams request previous? - initialProgress finishReporter - catch e => - if !Beam.Mcp.Stdio.isBrokenPipeError e then - Internal.traceMcp s!"workspace control completion failed id={req.id.label}: {e.toString}" - finishReporter - coordinator.finishRequest request <| - errorResponse req.id (RpcError.internalError e.toString) - finally - finishReporter - resolvePromise done - pure () + awaitRequests fence.priorRequests + coordinator.toolRequestResponse opts req admitted parsedParams request fence.previous? + initialProgress + match reporter? with + | none => run 0 + | some reporter => + try + run reporter.initialProgress + finally + finishReporterSafely req reporter.finish + coordinator.spawnOwnedRequest req request work (resolvePromise fence.done) private def isWorkspaceControl : Except String CallToolParams → Bool | .ok params => params.name == .leanDropWorkspace @@ -554,7 +642,7 @@ partial def runStdio (opts : Options) : IO Unit := do try loop catch e => - if Beam.Mcp.Stdio.isBrokenPipeError e then + if Beam.Mcp.Stdio.isClosedOutputError e then pure () else throw e diff --git a/Beam/Native/control_dir.c b/Beam/Native/control_dir.c new file mode 100644 index 00000000..afbdf301 --- /dev/null +++ b/Beam/Native/control_dir.c @@ -0,0 +1,26 @@ +/* +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: Emilio J. Gallego Arias +*/ + +#include +#include +#include +#include + +LEAN_EXPORT lean_obj_res lean_beam_lstat_mode( + b_lean_obj_arg path, + lean_obj_arg world) { + (void)world; +#if defined(_WIN32) + struct _stat status; + if (_stat(lean_string_cstr(path), &status) != 0) { +#else + struct stat status; + if (lstat(lean_string_cstr(path), &status) != 0) { +#endif + return lean_io_result_mk_error(lean_decode_io_error(errno, path)); + } + return lean_io_result_mk_ok(lean_box_uint32((uint32_t)(status.st_mode & 0777))); +} diff --git a/Beam/System.lean b/Beam/System.lean index 6b0b3b8f..fb13a9d9 100644 --- a/Beam/System.lean +++ b/Beam/System.lean @@ -10,6 +10,13 @@ open Lean namespace Beam +/-- Return the POSIX permission bits reported by `lstat`, without following the final symlink. -/ +@[extern "lean_beam_lstat_mode"] +private opaque lstatMode (path : @& String) : IO UInt32 + +def fileModeNoFollow (path : System.FilePath) : IO UInt32 := + lstatMode path.toString + def trimLine (text : String) : String := text.trimAscii.toString @@ -46,26 +53,6 @@ def commandAvailable (cmd : String) (args : Array String := #["--help"]) : IO Bo catch _ => pure false -def killCommand : IO String := do - let candidates := [System.FilePath.mk "/bin/kill", System.FilePath.mk "/usr/bin/kill"] - for candidate in candidates do - if ← candidate.pathExists then - return candidate.toString - if ← commandAvailable "kill" #["-l"] then - pure "kill" - else - throw <| IO.userError "could not find kill command" - -def pidAlive (pid : Nat) : IO Bool := do - let out ← IO.Process.output { cmd := (← killCommand), args := #["-0", toString pid] } - pure (out.exitCode == 0) - -def currentPidNamespace? : IO (Option String) := do - try - pure <| some (← readCmdTrim "readlink" #["/proc/self/ns/pid"]) - catch _ => - pure none - def utcTimestamp : IO String := do readCmdTrim "date" #["-u", "+%Y-%m-%dT%H:%M:%SZ"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 329dbc71..ad268338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ This project keeps a lightweight, reverse-chronological changelog. Dates use `YY ### Changed +- Wrapper daemons now have explicit session ownership: only `lean-beam ensure --hold` starts a + generation, ordinary wrapper commands attach to it, `--port` is accepted only by that owner-start + command, and holder exit cancels admitted requests before closing the daemon through an inherited + pipe without heartbeat leases or time-based retirement + ([#241](https://github.com/leanprover/lean-beam/pull/241), @ejgallego). - Long-running Lean operations now separate liveness status, request progress, and diagnostics. Sync and refresh use the discoverable `diagnostic_scope: "errors" | "all"` and `diagnostics_in_result` controls, while save and close-save use `diagnostic_scope`; the obsolete diff --git a/README.md b/README.md index d1354af2..61839176 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ including structured Lake options, dynamic libraries, and plugins already applie worker. Modules with batch-only `moreLeanArgs` fail with `saveUnsupportedSetup`: move shared `-D` settings to `leanOptions`, or use `lake build` when the arguments are intentionally batch-only. A running Lean server is not guaranteed to pick up Lake workspace configuration changes; after such a -change, run `lean-beam shutdown` before the next command that uses the Lean server. A successful +change, run `lean-beam shutdown`, then start a new foreground `lean-beam ensure --hold` wrapper +session before the next wrapper command that uses the Lean server. A successful checkpoint is normally sufficient while working; do not add an expensive clean build to every Beam loop. Final project validation should come from CI running `lake build` from clean Lake artifacts. If no successful clean CI result is available, or server-sensitive elaboration is suspected, use the diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index c21832f6..29ff8bb6 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -20,6 +20,12 @@ A Lean release line is the canonical `major.minor` family recorded in - Runtime bundle metadata schema 2 and install manifest schema 3. Install manifest schema 2 is cleanup-only compatibility during the 0.2 release line: identity and `lean-beam prune` may read it, but the installer does not reuse it. Remove the schema-2 decoder when 0.3 development opens. +- CLI session descriptor schema 2. It freezes a nonempty array of workspace bindings plus one + generation identity, lifecycle, endpoint, and capability. Schema-less, schema-1, and unknown + records are reported and remain fenced; normal startup does not decode, delete, or migrate them. + After independently stopping the generation that wrote an opaque record, an operator may + quarantine it with `lean-beam --root ROOT recover --force`. Current descriptors instead require + their exact generation ID. Persisted PIDs are never recovery signal capabilities. - MCP `2026-07-28` is the preferred stdio protocol revision. MCP `2025-11-25` remains a named transition target for initialization-based clients. Reconsider the legacy path before the 0.3 release once the clients named by the setup guide can all use per-request metadata. diff --git a/docs/CUSTOM_TOOLCHAINS.md b/docs/CUSTOM_TOOLCHAINS.md index dc50ec6d..946d0187 100644 --- a/docs/CUSTOM_TOOLCHAINS.md +++ b/docs/CUSTOM_TOOLCHAINS.md @@ -93,4 +93,4 @@ BEAM_STAGE0_TOOLCHAIN=lean4-dev bash tests/test-stage0-toolchain.sh The smoke skips when the requested elan toolchain is unavailable. When it runs, it installs Beam with `--custom-toolchain`, checks that `doctor` resolves the installed bundle and reports a -fingerprint, then starts Beam with `ensure`. +fingerprint, then starts and closes an explicit `ensure --hold` wrapper session. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ca91ef1e..67d4108f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -148,10 +148,19 @@ carries a continuation handle that names one, and daemon startup receives its in explicitly through `--workspace-id`. The public CLI still manages one daemon per project, but that policy stays in `Beam.Cli`: its request adapter supplies a CLI-owned private identifier. That value is an implementation detail, not part of the broker protocol. Broker stats and open-document -requests without an id remain process-wide and return only the `workspaces` map; the CLI scopes -those requests before sending them. `Beam.Broker.Op.workspaceScope` is the shared operation -classification; CLI and test adapters should use it instead of maintaining their own operation -lists. +requests without an id remain process-wide; stats report `uptimeMs` and the `workspaces` map. Every +broker request except `cancel` and `shutdown` is tracked even when it has no client request id; +anonymous requests use an internal admission token so disconnect cancellation cannot affect a later +request. Wrapper requests carry a generated request id so each one can be cancelled by its exact +admission handle. The CLI scopes those requests before sending them. +`Beam.Broker.Op.workspaceScope` is the shared operation classification; CLI and test adapters should +use it instead of maintaining their own operation lists. + +Workspace teardown must not wait for backend shutdown while holding `ServerRuntime.state`: reset, +drop, and runtime close detach backend sessions and commit the new workspace state atomically, then +wait for or terminate the detached processes after releasing the mutex. This keeps teardown of one +workspace from blocking state access for every other workspace. Other state transactions, including +session startup and restart, may still perform process I/O while holding that mutex. ## MCP Projection Changes @@ -165,7 +174,25 @@ The local descriptor lives in [Beam/Workspace/Protocol.lean](../Beam/Workspace/Protocol.lean). Every workspace-bound request must carry `{"workspace":{"root":"/absolute/project"}}`. Resolve it through [Beam/Lean/Workspace.lean](../Beam/Lean/Workspace.lean), canonicalize it before deriving the private -broker cache key, and never store a current/default workspace in MCP protocol state. +broker cache key, and never store a current/default workspace in MCP protocol state. MCP server +state owns only the optional shared `ServerRuntime`; workspace membership and canonical roots remain +broker-owned and must be observed through typed broker queries rather than a transport-side mirror. +`ServerState` also owns the runtime-control mutex used by every transport and direct request entry +point. Runtime creation, workspace eviction, and close are serialized there; close first transfers +the runtime out of `ServerState`, then drains it while preventing a competing creation. +In-process MCP lifecycle calls use the broker's typed `initWorkspaceWithConfig` and `dropWorkspace` +results directly; do not route them through broker JSON dispatch and decode them back into the same +types. + +CLI and MCP share semantic dispatch, not transport coordination. A daemon accepts one broker request +per socket connection, so the connection itself supplies response routing and disconnect lifetime. +MCP multiplexes requests over one stdio stream, so `Beam.Mcp.StdioServer` must own exact JSON-RPC ID +routing, serialized output, client cancellation, and workspace-control barriers. Both paths converge +on `ServerRuntime.dispatchRequestWithHandle`, whose admission handle is the shared cancellation and +drain boundary. `ServerRuntime` remains transport-agnostic; the daemon's private transport context +owns its endpoint, listener, and stop state. Keep the two ingress coordinators separate unless a +future transport has the same wire-level ownership rules; do not duplicate semantic operation +dispatch above that boundary. The executable path is split into importable modules: @@ -186,6 +213,9 @@ Keep these stdio invariants explicit: - the server emits no JSON-RPC requests to clients - request IDs preserve their string-versus-integer type and their original JSON spelling - ordinary calls may overlap; cache eviction is a full stream-order fence and shutdown drains work +- once a request is registered, synchronous setup must either transfer it to an owned worker or + complete it terminally; worker finalization always retires the request and releases its control + fence, including after task-start, reporting, or output failures - ordinary tool calls bind cancellation to the exact broker admission handle; do not reintroduce request-ID polling between MCP and the broker - routing/output locks do not acquire setup, progress, or per-request locks @@ -279,7 +309,8 @@ The broker is not a raw LSP proxy. Its narrow public job is still to expose smal internally it coordinates several responsibilities around the LSP process: - the CLI owns process identity: project-root detection, bundle selection, registry files, - endpoint/root validation, startup/shutdown, wrapper leases, and control-directory locks + endpoint/root/generation validation, explicit session ownership, startup/shutdown, and + control-directory locks - the broker owns request identity: daemon root validation, backend session lifetime, request dispatch, cancellation, active-request bookkeeping, transport errors, and the LSP document mirror - the LSP server and plugin own Lean/Rocq semantic facts: elaboration, diagnostics, progress, @@ -290,12 +321,31 @@ internally it coordinates several responsibilities around the LSP process: `ServerRuntime.dispatchRequestWithHandle` is the asynchronous admission boundary for in-process consumers such as MCP. It validates operation field ownership, registers the request's active identity, exposes one opaque `RequestHandle`, and owns unregistering that handle on success, -rejection, or exception. A handle uses a per-admission token and must become inert after that -lexical scope, including when a later request reuses the same client request ID. Keep ordinary -daemon and CLI dispatch on +rejection, or exception. A handle uses a per-admission token and must become +inert after that lexical scope, including when a later request reuses the same client request ID. +Keep ordinary daemon and CLI dispatch on `ServerRuntime.dispatchRequest`; transport layers must not mutate the active-request registry directly. Pending LSP requests must retain the same per-admission cancellation identity; after a -handle has been validated, never fall back to matching a reusable client request ID. +handle has been validated, never fall back to matching a reusable client request ID. Pass and await +the `PendingRequest` as one value instead of separating its promise from its cancellation reference. +Once that reference is marked, cancellation takes precedence over a concurrent backend failure; +an already-completed backend success remains successful. +After initialization, `sessionReaderLoop` is the backend session's only stdout reader. Shutdown +replies use the same pending-request store and reader loop; do not add a second direct stdout read. + +`ServerRuntime.close` is the shared runtime teardown boundary. It closes admission, marks every +admitted request for cancellation, shuts down backend sessions to unblock pending work, waits for +all admitted dispatch scopes to unregister, and performs a final backend sweep so a request that +was between admission and session creation cannot leave a late process behind. Concurrent and +repeated callers wait for the same result. Transport owners decide what triggers closure and how +their listener or stdio connection stops; they must not duplicate broker draining or backend +teardown. + +A newly spawned backend remains a provisional resource until its initialization response arrives +within the 30-second startup deadline and the broker sends `initialized`. Any initialization error, +timeout, or notification-write failure terminates and reaps that child before the acquisition fails; +only a fully initialized session enters workspace state. Keep this acquisition bracket intact so +runtime closure never has to discover an unowned provisional process. The thick part of the broker is request orchestration. For `sync`, `runAt`, `goals`, `runWith`, `release`, and `save`, the broker reads the source file, updates the LSP document mirror, waits for @@ -350,33 +400,101 @@ broker-derived decision. This wrapper path is easy to break accidentally, so keep the mental model simple. -What was broken: - -- Codex-style wrapper calls run in separate PID-isolated sandboxes. -- A later wrapper call could look at the daemon pid in the registry and think the daemon was dead, - even when the daemon was still alive and answering on its TCP endpoint. -- If one wrapper call started the daemon and then exited while sibling wrapper calls were still - using it, that exit could tear the daemon down mid-flight. - -What the fix does: - -- if the registry endpoint still answers, treat the daemon as live even if the recorded pid looks - wrong in the current sandbox -- if a wrapper call started the daemon, keep that wrapper call alive until overlapping sibling - wrapper calls for the same project root drain -- `lean-beam ensure --hold` gives agents an explicit foreground owner when they need daemon reuse - across separate PID-isolated shell invocations -- wrapper leases include PID namespace metadata, so same-namespace stale leases left by killed - wrappers are pruned without treating different sandbox namespaces as safe to probe by pid -- the regression for this path is +A CLI session descriptor is the schema-versioned `beam-daemon.json` selected by the control +directory. It contains one generation identity, capability, lifecycle, endpoint, and a nonempty +array of frozen workspace bindings. The public owner command currently creates one binding; the +shape permits a future explicitly configured static multi-workspace session without changing +request routing. Exactly one foreground `lean-beam ensure --hold` process owns the generation. It +starts the daemon in a dedicated process session, passes the identity, effective configuration +hash, and random capability through piped stdin, and retains the pipe's write end. Before creating +the lock or descriptor, Beam creates a missing control leaf with mode `0700`, or validates that an +existing leaf is a real, non-symlinked directory already using mode `0700`. It never changes an +existing selection's permissions. The mode-`0600` descriptor is written through an exclusive +random temporary path and publishes `live` or `draining`. Every wrapper request, including +cancellation, generation probes, and shutdown, presents the capability. The daemon watches the +pipe's read end; +EOF closes admission, marks admitted requests for cancellation, shuts down backend sessions, and +stops the listener. There is no heartbeat, lease, or time-based retirement fence. + +Ordinary wrapper commands never start a daemon or recompute its desired toolchain/bundle +configuration. Without taking the session mutation lock or creating control files, they select the +canonical root's frozen workspace binding and require an endpoint that answers for that workspace, +root, and exact generation. Atomic descriptor publication plus endpoint authentication makes a +concurrent lifecycle change fail closed. Identity probes have a bounded response deadline. A silent +or malformed endpoint fails closed. Ordinary lookup is observation-only: absent, legacy, malformed, +unsupported, draining, unreachable, or otherwise ambiguous descriptor states are never rewritten +by an attaching command. Persisted PIDs are display-only diagnostics, never probed, signalled, or +used as automatic stale-reclamation proof. Only the foreground owner may force termination, through +its retained child handle and process group. + +The owner watches its exact descriptor generation and daemon child. `lean-beam shutdown` changes +that generation from `live` to `draining` under the control lock before sending authenticated +shutdown. Normal holder exit likewise publishes `draining`, closes its pipe, waits for graceful +teardown, and, after the deadline, terminates the owned process group. It removes only the exact +generation after owned cleanup completes. An unexpected daemon or owner exit deliberately leaves +the descriptor fenced: startup does not infer complete process-tree exit from persisted PIDs. Once +the operator establishes that the old session is no longer authoritative, +`lean-beam --root ROOT recover --generation ID` quarantines that exact descriptor without signalling +any recorded process. Opaque legacy, unsupported, or malformed state requires `recover --force`. +A paused holder keeps its pipe open and remains valid without expiry. If the project root +disappears, cleanup uses the already resolved control path without recreating the project. + +Human commands may infer the nearest project root. The supported machine stream requires explicit +`--root` and a nonempty `clientRequestId`; its semantic JSON cannot supply `root`, `workspaceId`, +capability, dynamic workspace operations, or process-wide control operations such as `shutdown` and +`resetStats`. The wrapper selects the descriptor binding and injects session metadata. Lifecycle +shutdown remains the dedicated `lean-beam shutdown` command so it can publish `draining` first. Raw +port-oriented `beam-client` requests are maintainer/debug tooling. A wrapper-owned daemon rejects +`initWorkspace`, `listWorkspaces`, and `dropWorkspace`; a separately launched broker retains the +generic multi-workspace surface and has its own explicit owner. Broker runtime ownership is a typed +`ServerMode`: wrapper identity and capability cannot be constructed independently. + +The default control directory is `/.beam`, discoverable to project-scoped agents. +`--control-dir DIR` is an exact, stateless selection that every participant must repeat. +`BEAM_CONTROL_ROOT` must be absolute and hashes each canonical root below a writable base for +sandboxed/read-only roots. The selected directory is private to one local account; coordination is +supported between that account's processes, not across a group-shared control directory. Existing +directories must be prepared explicitly as mode `0700`; validation rejects symlinks, other file +types, and broader permissions without mutating them. + +Do not hide policy inside automatic fallback between these locations. A future multi-root CLI owner +should require a stable explicit control directory and freeze all bindings before publication. + +Keep these invariants covered: + +- only `ensure --hold` may create and publish a wrapper daemon generation +- a second owner is rejected while the current endpoint/root/generation identity is live +- ordinary wrapper commands are read-only with respect to registry and process lifecycle, including + in ambiguous or unsafe state; they attach to frozen owner configuration rather than recomputing it +- normal holder teardown retains a generation-specific draining fence until owned cleanup completes + and cannot remove a replacement; abnormal exit leaves the fence for explicit recovery +- recovery of a current descriptor requires both its exact generation and one of its recorded + workspace roots; a wrong-root caller cannot quarantine another session +- control preparation changes permissions only on a leaf Beam just created; an existing control + path must be a non-symlinked mode-`0700` directory and rejection leaves it untouched +- owner EOF, explicit shutdown, and project-root disappearance all close admission before backend + teardown and complete with bounded child cleanup +- persisted numeric PIDs are never signalled or used for automatic stale reclamation +- every wrapper request is bound to its random generation capability, and transport frame, initial + request, connection, and task counts are bounded +- request IDs are unique and cancellation is exact within a workspace; per-admission tokens retain + exact disconnect and close semantics +- the regressions for this path are + [tests/test-beam-wrapper-daemon.sh](../tests/test-beam-wrapper-daemon.sh) and [tests/test-beam-wrapper-sandbox.sh](../tests/test-beam-wrapper-sandbox.sh) -The generic lock/process helpers live in [Beam/Cli/Lock.lean](../Beam/Cli/Lock.lean). Project -daemon control locks use a bounded wait so a live but stuck wrapper process produces owner -diagnostics instead of making later clients wait silently; `BEAM_CONTROL_LOCK_TIMEOUT_MS` can shorten -or lengthen that wait for local debugging. Bundle build locks intentionally keep the lower-level -unbounded helper because another process may legitimately be compiling a helper bundle. Reusable CLI -argument parsing lives in [Beam/Cli/Args.lean](../Beam/Cli/Args.lean). Project-root inference, +Generic process helpers live in [Beam/System.lean](../Beam/System.lean). Kernel-backed stable file locks live in +[Beam/Cli/Lock.lean](../Beam/Cli/Lock.lean); lock files remain after release so contenders always +coordinate on the same inode, while the kernel releases ownership when a process exits. Project +daemon control mutations use a bounded wait so a live but stuck wrapper process produces owner +diagnostics instead of making another mutation wait silently; ordinary attachment does not take +this lock. +`BEAM_CONTROL_LOCK_TIMEOUT_MS` can shorten or lengthen that wait for local debugging. Bundle build +locks intentionally keep the lower-level unbounded helper because another process may legitimately +be compiling a helper bundle. The shell installer's `.install-lock` remains an atomic directory +compatibility boundary: it is never stale-reaped, and a crashed installer requires explicit +operator recovery. Reusable CLI argument parsing lives in +[Beam/Cli/Args.lean](../Beam/Cli/Args.lean). Project-root inference, Lean toolchain lookup, and Rocq command discovery live in [Beam/Cli/Project.lean](../Beam/Cli/Project.lean). Shared filesystem path helpers live in [Beam/Path.lean](../Beam/Path.lean). Use them instead of copying string-prefix checks or raw `IO.FS.realPath` wrappers: @@ -400,9 +518,11 @@ and shared with Lake/elaboration work, so a tiny task that blocks in an OS read normal-priority work on low-core runners. The cheap regression guard is [scripts/check-task-priority.sh](../scripts/check-task-priority.sh). -Daemon registry management, daemon startup/reuse, endpoint selection, and wrapper leases live in -[Beam/Cli/DaemonManager.lean](../Beam/Cli/DaemonManager.lean). Broker request plumbing, progress -messages, cancellation-on-interrupt, and response failure notes live in +Shared registry, startup-log, and incident paths live in +[Beam/Daemon/Paths.lean](../Beam/Daemon/Paths.lean). Daemon registry management, explicit owner +lifetime, endpoint selection, and explicit non-signalling recovery live in +[Beam/Cli/DaemonManager.lean](../Beam/Cli/DaemonManager.lean). Broker request plumbing, +progress messages, cancellation-on-interrupt, and response failure notes live in [Beam/Cli/Broker.lean](../Beam/Cli/Broker.lean). User-facing stdout/stderr formatting helpers live in [Beam/Cli/Output.lean](../Beam/Cli/Output.lean). Doctor, validated/compatible toolchain registry, install layout/manifest, and MCP config reporting live in diff --git a/docs/MCP.md b/docs/MCP.md index 0b848205..4e7e3749 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -130,6 +130,29 @@ After editing a lakefile, manifest, package override, `lean-toolchain`, Lean opt dynamic libraries, drop that workspace or restart the MCP server before the next request. Re-syncing inside the old Lean process is not sufficient to reload workspace configuration. +## Transport Lifetime + +The `lean-beam-mcp` stdio process owns its optional in-process broker runtime. MCP clients do not +start or attach to a wrapper daemon and do not need a separate `lean-beam ensure --hold` owner. The +first Lean operation that needs broker execution creates the runtime lazily; later descriptors share +that runtime while the broker remains authoritative for workspace membership. Feedback and cache +eviction can inspect or update an absent runtime without creating one. `lean_drop_workspace` evicts +one cached workspace but does not end the stdio session. + +Closing stdin or reaching EOF closes MCP request admission. The server cooperatively cancels active +cancellable requests, waits for every admitted request and non-cancellable workspace eviction to +finish, then closes the broker runtime and all remaining backend sessions. A JSON-RPC request ID is +active only until its request reaches terminal completion. The server retires that exact admission +before publishing its terminal response, so a client may reuse the ID after observing the response; +the completion barrier is resolved only after the terminal response write attempt finishes, including +when that write fails. + +CLI ingress has a different transport lifetime: a broker daemon accepts one request per socket +connection, and disconnecting that connection cancels its exact broker admission. MCP carries many +overlapping requests on one stdio stream and therefore owns application-level ID routing, +cancellation, output serialization, and workspace-control fences. Both paths use the same broker +request dispatcher and runtime teardown boundary. + ## Code Ownership - [Beam/Broker/Protocol.lean](../Beam/Broker/Protocol.lean) owns broker request, response, handle, @@ -146,9 +169,9 @@ inside the old Lean process is not sufficient to reload workspace configuration. - [Beam/Mcp/Protocol.lean](../Beam/Mcp/Protocol.lean) owns the current MCP JSON-RPC helpers. - [Beam/Mcp/Runtime.lean](../Beam/Mcp/Runtime.lean) owns root-to-runtime configuration. - [Beam/Mcp/SelfCheck.lean](../Beam/Mcp/SelfCheck.lean) owns the installed-wrapper self-check. -- [Beam/Mcp/Server.lean](../Beam/Mcp/Server.lean) owns descriptor resolution, lazy cache dispatch, - the typed protocol-family state machine, the physically separate application registry, and the - synchronous protocol-test seam. +- [Beam/Mcp/Server.lean](../Beam/Mcp/Server.lean) owns descriptor resolution, lazy broker-runtime + access, the typed protocol-family state machine, and the synchronous protocol-test seam. The + broker runtime is authoritative for workspace state. - [Beam/Mcp/StdioServer.lean](../Beam/Mcp/StdioServer.lean) owns the permanent stdin reader, concurrent coordination, cancellation, cache-control barriers, and serialized output. diff --git a/docs/ROCQ.md b/docs/ROCQ.md index c09bb7e8..eb7b18f6 100644 --- a/docs/ROCQ.md +++ b/docs/ROCQ.md @@ -55,13 +55,20 @@ bash tests/setup-rocq-opam.sh Rocq commands are available through the same installed `lean-beam` wrapper: ```bash -lean-beam ensure rocq +# keep this foreground owner running in one terminal/session +lean-beam ensure rocq --hold + +# issue probes from another terminal/session lean-beam doctor rocq lean-beam rocq-goals-after "Demo.v" 2 8 lean-beam rocq-goals-prev "Demo.v" 2 8 lean-beam rocq-goals-prev "Demo.v" 2 8 "intro x." ``` +The holder is the explicit owner of the wrapper daemon and its `coq-lsp` backend. Interrupt it, or +run `lean-beam shutdown`, when the session is finished. Ordinary wrapper commands attach to that +session and do not start a daemon implicitly. + Use `rocq-goals-after` to inspect goals after an existing sentence. Use `rocq-goals-prev` to inspect goals before a sentence, or with extra text to inspect an intermediate tactic prefix while porting a Rocq proof step to Lean. diff --git a/docs/SETUP.md b/docs/SETUP.md index 9397dadb..793fb542 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -163,8 +163,11 @@ not currently in use. Restart active agent and MCP client sessions before any `prune --apply`; otherwise a process may still be running from a runtime selected for removal. A later request rebuilds any needed bundle -that was pruned. Pruning uses the same install lock as the installer and each selected bundle's -build lock, and refuses symlinked installed bundle-cache roots or symlinked and unmarked runtime +that was pruned. Pruning uses the same atomic-directory install lock as the shell installer and a +kernel-backed file lock for each selected bundle. Bundle lock files remain as stable coordination +inodes after release; kernel ownership disappears automatically when the holder exits. The install +lock is never stale-reaped, so a crashed installer fails closed and requires explicit recovery. +Pruning also refuses symlinked installed bundle-cache roots or symlinked and unmarked runtime directories. Apply is incremental rather than transactional: Beam validates and removes one displayed path at a @@ -200,10 +203,14 @@ lean-beam doctor Command positions use Lean/LSP coordinates: line and character are zero-based, and character counts UTF-16 code units. -Then start the per-project daemon and ask questions against a saved Lean file in that project: +Start one foreground owner for the wrapper session and keep it running. In another terminal or agent +process, ask questions against saved Lean files in that project: ```bash -lean-beam ensure +# terminal/session 1 +lean-beam ensure --hold + +# terminal/session 2 update_json="$(lean-beam update "Foo.lean")" printf '%s\n' "$update_json" version="$(printf '%s\n' "$update_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["version"])')" @@ -213,6 +220,69 @@ lean-beam goals before "Foo.lean" "$version" 10 2 lean-beam run-at "Foo.lean" "$version" 10 2 "exact trivial" ``` +`lean-beam ensure --hold` is the only wrapper command that starts the per-project daemon. Its +inherited ownership pipe defines the session lifetime: interrupt the holder, or run +`lean-beam shutdown`, to close the daemon and its backend processes. Plain `lean-beam ensure` and +all other wrapper commands attach to an existing owner and fail with a recovery command when none +is live. Attaching commands use the owner's frozen configuration and do not rebuild a competing +desired configuration. A second owner does resolve its proposed configuration and reports any +mismatch while preserving the old owner. During shutdown the registry reports `draining` and a +replacement owner is refused until the old process tree has exited. MCP clients do not need a +separate holder; the stdio MCP process owns its runtime session. + +The default session descriptor and lock live in `/.beam`. This is intentional for +project-scoped agent sandboxes: clients that can access the same workspace can discover the same +session. Before creating a lock or capability-bearing descriptor, Beam makes the selected control +directory account-private (`0700`) when the leaf does not exist; the published descriptor is +`0600`. An existing selection must already be a real, non-symlinked directory with mode `0700`, or +Beam refuses it without changing its permissions. This permits coordination between sandboxes and +agents running as the same local account, but a group-shared or traversable control directory is +not a supported authentication boundary. Use an exact alternate directory when the project is +read-only or several same-account clients need another stable control plane: + +```bash +lean-beam --root /workspace/a --control-dir /workspace/control ensure --hold +lean-beam --root /workspace/a --control-dir /workspace/control stats +``` + +Every participant must supply the same `--root` and `--control-dir`; Beam does not search alternate +control directories. If the exact directory already exists, prepare its `0700` mode explicitly; +Beam will not adopt it by silently changing permissions. `BEAM_CONTROL_ROOT=/writable/base` is the +sandbox convenience form and must be absolute: Beam derives a separate hashed directory for each +canonical root below that base. An +explicit control directory is also the intended future location for a statically configured +multi-workspace CLI session. The current public owner command still publishes one frozen workspace, +and wrapper mode does not allow runtime `init_workspace`, `list_workspaces`, or `drop_workspace` requests. The +supported semantic `request-stream` also excludes process-wide `shutdown` and `reset_stats`; use +the dedicated `lean-beam shutdown` command for lifecycle control. +Use a stable external control directory when ownership must remain fenced while the project path is +deleted and recreated; deleting a project-local `.beam` necessarily deletes its default fence. + +An abnormal owner or broker exit leaves the descriptor as a safety fence. After independently +establishing that the recorded generation is no longer authoritative, quarantine that exact record +without signalling its recorded PIDs: + +```bash +lean-beam --root /workspace/a recover --generation GENERATION_ID +``` + +Use the same `--control-dir` selection when applicable. `recover --force` is reserved for opaque +legacy, unsupported, or malformed descriptors. Recovery preserves the old file under a +`beam-daemon.recovered-*.json` name for diagnosis. + +For a current descriptor, the selected `--root` must be one of its recorded workspace bindings; +using the same control directory with an unrelated root cannot quarantine the session. + +Machine clients should avoid root auto-detection and raw port/session fields: + +```bash +lean-beam --root /workspace/a request-stream \ + '{"op":"stats","clientRequestId":"agent-stats-1"}' +``` + +The wrapper selects the frozen workspace and injects root, workspace identity, generation +capability, and endpoint. `beam-client --port ...` is lower-level maintainer/debug tooling. + The `python3` line extracts `result.version` for shell examples. You can also copy that version number from the printed `lean-beam update` JSON. @@ -264,9 +334,9 @@ checks. The running Lean server and existing file workers are not guaranteed to pick up Lake workspace configuration changes. After editing a lakefile, manifest, package override, `lean-toolchain`, Lean -options, plugins, or dynamic libraries, run `lean-beam shutdown` before the next command that uses -the Lean server. `lean-beam refresh` reopens a file within the current server and is not sufficient -for this case. +options, plugins, or dynamic libraries, run `lean-beam shutdown`, then start a new +`lean-beam ensure --hold` owner before the next wrapper command that uses the Lean server. +`lean-beam refresh` reopens a file within the current server and is not sufficient for this case. ### Final Batch Validation diff --git a/docs/STATUS.md b/docs/STATUS.md index 1d669ce7..72abc6f3 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -131,14 +131,23 @@ saved accepted text, the intended future direction is for `lean-beam update` or reuse matching speculative execution rather than replaying it from scratch. Beam would still not apply the source edit. -For programmatic local consumers, the preferred machine-readable surface is the JSON stream exposed -by `beam-client request-stream`; wrapper stderr should be treated as human-facing. Broker responses -require an explicit top-level `ok` boolean, giving projection layers an unambiguous success/error -discriminator. A successful response always includes `result`; response and stream envelopes reject -undeclared fields, and typed save/close-save results reject incomplete or extended artifact shapes. -All raw stream variants use the same `kind`, `payload`, and optional outer `clientRequestId` fields; -the terminal response payload does not duplicate transport correlation. Exact event ordering and -examples live in [SYNC_AND_DIAGNOSTICS.md](SYNC_AND_DIAGNOSTICS.md#raw-broker-stream). +For programmatic local consumers of a wrapper session, the supported machine-readable surface is +`lean-beam --root ROOT [--control-dir DIR] request-stream `. The request JSON contains the +operation, its arguments, and a nonempty `clientRequestId`; it cannot select a workspace, root, or +capability, and it cannot issue workspace-administration or process-wide control operations. The +wrapper canonicalizes the explicit root, selects its static workspace binding from the session +descriptor, and injects routing and authentication. Wrapper stderr is human-facing. +The port-oriented `beam-client request-stream` remains maintainer/debug tooling for separately +managed brokers. A wrapper daemon exists only while its foreground `lean-beam ensure --hold` owner +is alive. Attaching requests do not acquire daemon ownership. A separately launched standalone +daemon has its own explicit process owner. Broker +responses require an explicit top-level `ok` boolean, giving projection layers an unambiguous +success/error discriminator. A successful response always includes `result`; response and stream +envelopes reject undeclared fields, and typed save/close-save results reject incomplete or extended +artifact shapes. All raw stream variants use the same `kind`, `payload`, and optional outer +`clientRequestId` fields; the terminal response payload does not duplicate transport correlation. +Exact event ordering and examples live in +[SYNC_AND_DIAGNOSTICS.md](SYNC_AND_DIAGNOSTICS.md#machine-broker-stream). `lean-beam-mcp` is the experimental stdio MCP entry point. User setup lives in [SETUP.md](SETUP.md#mcp-setup); implementation, protocol, tool-list, and conformance notes live in @@ -177,11 +186,58 @@ examples live in [SYNC_AND_DIAGNOSTICS.md](SYNC_AND_DIAGNOSTICS.md#raw-broker-st - In sandboxed agent environments, Beam daemon startup itself may require elevated permissions even when the installed bundle and project-local `.beam` paths resolve correctly. +- Wrapper sessions use explicit ownership. `lean-beam ensure --hold` is the only wrapper command that + starts a project daemon; it passes the daemon an inherited pipe and remains alive as the owner. + Ordinary wrapper calls, including plain `lean-beam ensure`, only attach to that generation and + fail with the exact owner-start command when none is live. The optional `--port` override belongs + only to `ensure --hold`; attaching commands reject it. Owner EOF shuts down request admission, + marks admitted requests for cancellation, and closes backend sessions and the daemon after those + requests drain; a backend success that completed before cancellation remains successful. This + happens without heartbeat timeouts or filesystem leases and works across PID namespaces because + authority does not depend on observing persisted PIDs. Endpoint, root, and generation-identity + validation are authoritative. Each wrapper request carries a random per-generation capability + from a mode-`0600` registry inside a mode-`0700` control directory. A paused owner retains the + session; a killed owner closes the pipe; explicit + `lean-beam shutdown` changes the descriptor to `draining`, and that fence remains until normal + owner cleanup has reaped the daemon leader after graceful or process-group teardown. + Ordinary lookups take no mutation lock, create no control files, use the frozen workspace + configuration, and preserve unsafe session state. A competing owner computes its proposed + configuration but cannot replace a mismatched live owner. +- Abnormal or ambiguous state is never reclaimed automatically. The descriptor remains as a fence + after an unexpected broker/owner exit. Once the operator has established that the matching + session is no longer authoritative, `lean-beam --root ROOT recover --generation ID` quarantines + that exact descriptor without signalling persisted PIDs. Legacy, unsupported, or malformed + descriptor state requires the deliberately broader `recover --force` form. Current-descriptor + recovery additionally requires a root recorded in that descriptor, so a wrong-root caller using + the same control directory cannot quarantine the session. +- The default authoritative descriptor is `/.beam/beam-daemon.json`, which keeps discovery + available inside project-scoped agent sandboxes. `--control-dir DIR` selects one exact alternate + control directory; callers must repeat the same selection for every owner, request, diagnostic, + shutdown, and recovery command. `BEAM_CONTROL_ROOT` is the sandbox convenience that derives a + per-root subdirectory below an absolute writable base. Beam requires every selected control + directory to be account-private (`0700`) before creating its lock or capability descriptor: it + creates and privatizes a missing leaf, but accepts an existing path only when it is a real, + non-symlinked mode-`0700` directory. It rejects broader existing permissions without changing + them. A stable + explicit control directory is also the intended future boundary for a statically configured + multi-workspace CLI session; dynamic + workspace mutation remains unavailable in wrapper mode. +- Deleting the project tree also deletes its default project-local fence. Workflows that may remove + and immediately recreate the same canonical path, and need exclusion to survive that operation, + should select a stable external `--control-dir`; this tradeoff keeps the default usable from + project-scoped agent sandboxes without assuming access to a host-global runtime directory. +- Wrapper-owned brokers currently use authenticated loopback TCP. The supported trust boundary is + one local OS account with a private control directory and registry-file permissions; another user who can only discover + the port cannot issue requests without the generation capability. A manually launched standalone + `beam-daemon` has no wrapper registry capability and is maintainer tooling, not a shared-host + service. Unix-domain/per-user native IPC remains a possible later transport improvement. - A startup failure that reports `operation not permitted` through `.beam/beam-daemon-startup.log` is usually an environment restriction, not a bundle-resolution mismatch. -- Beam daemon disappearance errors include registry/log context and write a JSON incident record under - `.beam/daemon-failures/` or the per-root subdirectory of `BEAM_CONTROL_DIR`. Beam keeps the latest - 50 incident records and `lean-beam doctor` lists recent incident paths. +- Typed broker transport, invalid-response, and response-timeout failures include registry/log + context and write a JSON incident record below the selected control directory. Incident kinds are `brokerTransportFailure`, + `invalidBrokerResponse`, and `brokerResponseTimeout`; callback/display failures do not create + daemon incidents. Beam keeps the latest 50 incident records, and `lean-beam doctor` lists recent + incident paths. - A standalone Beam daemon watches its canonical project root. If a git worktree or project directory is removed while the daemon is active, it shuts down its backend sessions and exits instead of remaining undiscoverable after its project-local registry disappears. A later wrapper @@ -238,8 +294,8 @@ examples live in [SYNC_AND_DIAGNOSTICS.md](SYNC_AND_DIAGNOSTICS.md#raw-broker-st worker has already applied them; batch-only `moreLeanArgs` fail with `saveUnsupportedSetup`. - Beam does not detect Lake workspace configuration changes during a running Lean session. After editing a lakefile, manifest, package override, `lean-toolchain`, Lean options, plugins, or dynamic - libraries, run `lean-beam shutdown` before the next command that uses the Lean server; - `lean-beam refresh` does not restart the server. + libraries, run `lean-beam shutdown`, then start a new `lean-beam ensure --hold` owner before the + next wrapper command that uses the Lean server; `lean-beam refresh` does not restart the server. - A Beam checkpoint contains the Lean server's accepted environment. Elaborators can distinguish server execution from batch execution, so exceptional custom elaboration can produce an artifact that differs from a fresh `lake build` artifact. Successful checkpoints are normally diff --git a/docs/SYNC_AND_DIAGNOSTICS.md b/docs/SYNC_AND_DIAGNOSTICS.md index c319fd4c..56540b82 100644 --- a/docs/SYNC_AND_DIAGNOSTICS.md +++ b/docs/SYNC_AND_DIAGNOSTICS.md @@ -18,7 +18,7 @@ document version and return `changed: false`. `lean-beam sync` is the diagnostics/readiness barrier for a Lean file. It opens or updates the tracked file, waits for diagnostics for the current document version, streams fresh request diagnostics, and returns a machine-readable JSON verdict for that version. Wrapper stdout uses -stable, agent-oriented field ordering; `beam-client request-stream` is the compact one-line JSON +stable, agent-oriented field ordering; `lean-beam --root ROOT request-stream` is the compact one-line JSON stream for programmatic event consumers. The returned document `version` is the snapshot token for broker, MCP, and wrapper callers. @@ -117,15 +117,30 @@ Their transport types differ by surface. | Streamed diagnostics | Lean-published events observed while a request is pending. | MCP `notifications/message` with logger `lean.diagnostic`; Beam stream `diagnostic` events; CLI stderr diagnostics. | | Current result | Stable synced-state verdict for one document version. | Final broker/CLI `diagnostics`, `readiness`, and `fileProgress` fields; MCP spells the progress field `document_progress`. | -Wrapper stderr is the human-facing surface. Machine consumers should use final stdout JSON or the -broker JSON stream exposed by `beam-client request-stream`. - -### Raw Broker Stream - -`beam-client request-stream` prints one compact JSON object per line, in the order the broker -observed it. A request may produce any number of `fileProgress` and `diagnostic` messages, followed -by exactly one terminal `response`; the response is last and no later message belongs to that -request. +Wrapper stderr is the human-facing surface. Machine consumers of an owned wrapper session should +use final stdout JSON or `lean-beam --root ROOT [--control-dir DIR] request-stream `. + +### Machine Broker Stream + +`lean-beam --root ROOT request-stream` prints one compact JSON object per line, in the order the +broker observed it. Its input is a semantic project request with a required nonempty +`clientRequestId`; callers cannot supply `root`, `workspaceId`, `daemonCapability`, executable +configuration, workspace administration operations, or process-wide `shutdown` / `reset_stats`. +Use the dedicated `lean-beam shutdown` command for lifecycle control. A request may produce any +number of `fileProgress` and `diagnostic` messages, followed by exactly one terminal `response`; the +response is last and no later message belongs to that request. + +Keep the session's `lean-beam ensure --hold` owner active for the request lifetime. Only the holder +starts the daemon; the root-aware machine client reads the mode-`0600` descriptor, selects its +frozen workspace binding, and injects routing and the per-generation capability. Requests +participate in typed request admission and workspace-scoped cancellation but do not own the daemon. +The raw `beam-client --port ... request-stream` surface requires complete internal request fields +and is maintainer/debug tooling for separately managed brokers. + +The selected root chooses a workspace runtime; it is not a filesystem authorization boundary. +Relative paths resolve below that root, while absolute paths may identify dependency sources. Beam +can also execute Lean metaprogramming with IO, so callers that require filesystem isolation must +sandbox the owner process itself. Every stream variant uses the same `kind`, `payload`, and optional correlation envelope. When the request supplies `clientRequestId`, each message repeats it on that outer stream envelope: diff --git a/docs/TESTING.md b/docs/TESTING.md index 0559af10..0371585d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -104,14 +104,36 @@ Additional Beam lanes: Current Beam coverage includes: -- fast Beam daemon smoke, request-stream, save-stream, startup-handshake, tracked-diagnostic dedup, - exact broker request-handle lifetime, protocol tests, and validated-toolchain/release-line CI - policy consistency through +- fast Beam daemon smoke, request-stream, save-stream, startup-handshake with failed provisional + backend cleanup, tracked-diagnostic dedup, + exact broker request-handle lifetime, identity-matched daemon-generation probes, terminal shutdown + response delivery, shutdown after the requesting TCP client resets its connection, protocol tests, + and validated-toolchain/release-line CI policy consistency through [tests/test-beam-fast.sh](../tests/test-beam-fast.sh) -- wrapper coverage through [tests/test-beam-wrapper.sh](../tests/test-beam-wrapper.sh), which aggregates focused probe, runtime, sync/save, handle, and diagnostic slices +- wrapper coverage through [tests/test-beam-wrapper.sh](../tests/test-beam-wrapper.sh), which reports + focused probe, runtime, sync/save, handle, and diagnostic slices independently - focused daemon lifecycle coverage in [tests/test-beam-wrapper-daemon.sh](../tests/test-beam-wrapper-daemon.sh), - including self-termination after the project worktree disappears -- Linux-only PID-isolated sandbox wrapper coverage in [tests/test-beam-wrapper-sandbox.sh](../tests/test-beam-wrapper-sandbox.sh) + including the no-implicit-start contract, duplicate-owner rejection, Beam and non-Beam endpoint + collision safety without cross-project disclosure, authenticated generation probes, mode-`0700` + control-directory and mode-`0600` registry publication, rejection of symlinked or non-private + existing control paths without mutating their targets, wrong-root recovery rejection with + byte-for-byte descriptor preservation, unauthorized-shutdown rejection without listener teardown, oversized-frame + and first-message limits, a bounded identity probe against a silent non-Beam listener, cross-root + unsafe-registry preservation that does not affect the daemon + serving the other root, configuration-drift preservation of the owner and active request, + explicit shutdown, cancellation of requests active during shutdown or owner loss, + exact-generation cleanup that preserves a replacement registry, a published draining fence while + a daemon is paused, rejection of attachment or replacement while that generation remains + published, forced process-group cleanup of the daemon and its backend, + holder reporting after an unexpected daemon crash, abrupt owner death through inherited-pipe EOF, + read-only crash-fence lookup, exact-generation non-signalling recovery, explicit control-directory + selection, root-aware machine request routing, and self-termination after the project worktree + disappears without recreating it +- Linux-only PID-isolated sandbox wrapper coverage in [tests/test-beam-wrapper-sandbox.sh](../tests/test-beam-wrapper-sandbox.sh), + including cross-namespace endpoint attachment, duplicate-owner rejection, a paused owner without + time-based expiry, explicit shutdown, killed-owner EOF cleanup, fail-closed preservation of an + unavailable foreign-domain descriptor before explicit recovery, distinct generation identity, + and the absence of legacy lease/retirement artifacts - zero-build save replay, structured-setup support, batch-only-argument rejection, and stale-save race coverage in [tests/test-beam-save-olean.sh](../tests/test-beam-save-olean.sh) @@ -218,13 +240,14 @@ PYTHONDONTWRITEBYTECODE=1 python3 tests/test-mcp-stdio.py \ ``` This scenario covers out-of-order tool responses, exact string/numeric request-ID separation, -duplicate active-ID suppression, exact modern and legacy broker cancellation, per-request progress -ordering, deterministic overlap between a gated request in one workspace and a fast request in -another, single-flight first use, simultaneous cold first use of distinct roots, stateless -multi-root isolation, non-cancellable cache eviction with ordering on both sides of the global -fence, lazy recreation, and EOF cancellation and teardown. The full stdio suite also checks modern -request-ID reuse after a terminal response and rejects a proof handle carried across an MCP process -restart. The slow Beam suite runs +duplicate active-ID suppression, repeated asynchronous request-ID reuse after terminal responses, +exact modern and legacy broker cancellation, per-request progress ordering, deterministic overlap +between a gated request in one workspace and a fast request in another, single-flight first use, +simultaneous cold first use of distinct roots, stateless multi-root isolation, non-cancellable cache +eviction with ordering on both sides of the global fence, lazy recreation, and EOF cancellation and +teardown. The full stdio suite also checks modern request-ID reuse, synchronous and workspace-control +closed-output teardown, and rejects a proof handle carried across an MCP process restart. The slow +Beam suite runs `--scenario multi-toolchain-workspaces` after installing both fixture toolchains and verifies that one MCP process keeps both project-specific Lean sessions active. diff --git a/lakefile.lean b/lakefile.lean index 6a9e94d2..58235290 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -11,12 +11,20 @@ open System package "beam" where +target beamControlDirObj (pkg) : FilePath := do + let srcFile := pkg.dir / "Beam" / "Native" / "control_dir.c" + let oFile := pkg.buildDir / "native" / "control_dir.o" + let srcTarget ← inputTextFile srcFile + buildFileAfterDep oFile srcTarget fun srcFile => do + compileO oFile srcFile #["-I", (← getLeanIncludeDir).toString, "-fPIC"] + lean_lib Beam.LSP where globs := #[.andSubmodules `Beam.LSP] defaultFacets := #[`shared] lean_lib Beam where defaultFacets := #[`shared] + moreLinkObjs := #[beamControlDirObj] lean_lib BeamTest where srcDir := "tests/lean" diff --git a/scripts/lean-beam b/scripts/lean-beam index d9b04562..fdc5cd34 100755 --- a/scripts/lean-beam +++ b/scripts/lean-beam @@ -11,28 +11,29 @@ usage() { usage: lean-beam --version lean-beam version - lean-beam [--root PATH] [--port N] ensure [lean|rocq] [--hold] - lean-beam [--root PATH] [--port N] run-at [--stdin | --text-file | -- | ] - lean-beam [--root PATH] [--port N] run-at-handle [--stdin | --text-file | -- | ] - lean-beam [--root PATH] [--port N] hover - lean-beam [--root PATH] [--port N] signature-help - lean-beam [--root PATH] [--port N] definition - lean-beam [--root PATH] [--port N] references [--include-declaration|--exclude-declaration] - lean-beam [--root PATH] [--port N] document-symbols - lean-beam [--root PATH] [--port N] workspace-symbols - lean-beam [--root PATH] [--port N] goals before|after - lean-beam [--root PATH] [--port N] todo [--kind ...] [--suggest none|basic] - lean-beam [--root PATH] [--port N] run-with > [--stdin | --text-file | -- | ] - lean-beam [--root PATH] [--port N] run-with-linear > [--stdin | --text-file | -- | ] - lean-beam [--root PATH] [--port N] release > - lean-beam [--root PATH] [--port N] update - lean-beam [--root PATH] [--port N] sync [+all-diagnostics] - lean-beam [--root PATH] [--port N] refresh [+all-diagnostics] - lean-beam [--root PATH] [--port N] save [+all-diagnostics] - lean-beam [--root PATH] [--port N] close - lean-beam [--root PATH] [--port N] close-save [+all-diagnostics] - lean-beam [--root PATH] [--port N] rocq-goals-after [text...] - lean-beam [--root PATH] [--port N] rocq-goals-prev [text...] + lean-beam [--root PATH] ensure [lean|rocq] + lean-beam [--root PATH] [--port N] ensure [lean|rocq] --hold + lean-beam [--root PATH] run-at [--stdin | --text-file | -- | ] + lean-beam [--root PATH] run-at-handle [--stdin | --text-file | -- | ] + lean-beam [--root PATH] hover + lean-beam [--root PATH] signature-help + lean-beam [--root PATH] definition + lean-beam [--root PATH] references [--include-declaration|--exclude-declaration] + lean-beam [--root PATH] document-symbols + lean-beam [--root PATH] workspace-symbols + lean-beam [--root PATH] goals before|after + lean-beam [--root PATH] todo [--kind ...] [--suggest none|basic] + lean-beam [--root PATH] run-with > [--stdin | --text-file | -- | ] + lean-beam [--root PATH] run-with-linear > [--stdin | --text-file | -- | ] + lean-beam [--root PATH] release > + lean-beam [--root PATH] update + lean-beam [--root PATH] sync [+all-diagnostics] + lean-beam [--root PATH] refresh [+all-diagnostics] + lean-beam [--root PATH] save [+all-diagnostics] + lean-beam [--root PATH] close + lean-beam [--root PATH] close-save [+all-diagnostics] + lean-beam [--root PATH] rocq-goals-after [text...] + lean-beam [--root PATH] rocq-goals-prev [text...] lean-beam [--root PATH] feedback-report --stdin|--input [--bundle none|dir|zip] [--output-dir ] [--no-redact] lean-beam prune [--apply] [--bundles] lean-beam validated-toolchains [lean] @@ -42,16 +43,22 @@ usage: lean-beam [--root PATH] stats lean-beam [--root PATH] reset-stats lean-beam [--root PATH] shutdown + lean-beam [--root PATH] [--control-dir DIR] recover --generation ID | --force + lean-beam --root PATH [--control-dir DIR] request-stream lean-beam [--root PATH] cancel notes: + - pass --control-dir DIR before the command to select one exact alternate session control directory; repeat it for every participant - lean-beam keeps the Lean wrapper surface primary; optional Rocq goal probes are documented in docs/ROCQ.md - - common Rocq entry points are `lean-beam ensure rocq`, `lean-beam doctor rocq`, `lean-beam rocq-goals-after`, and `lean-beam rocq-goals-prev` + - start a Rocq session with `lean-beam ensure rocq --hold`; plain `ensure rocq` only checks and warms that owned session + - other common Rocq entry points are `lean-beam doctor rocq`, `lean-beam rocq-goals-after`, and `lean-beam rocq-goals-prev` - run `lean-beam update ` first, then pass its returned `version` to Lean position/range probes - run `lean-beam sync ` when you also need the diagnostics/readiness barrier - for multiline text-carrying Lean probes, prefer `--stdin` or `--text-file `; use `--` before text that starts with `--` - for handle-based commands, use `--handle-file ` when you do not want to inline handle json - - use `ensure --hold` when a PID-isolated command runner needs one foreground process to keep a newly-started daemon alive + - wrapper commands require one live session owner; start `ensure --hold` in a foreground process and keep it running + - plain `ensure` checks and warms that owned session but never starts one implicitly + - abnormal session state remains fenced; after verifying the old generation is gone, use `recover --generation ID` - set `BEAM_DEBUG_TEXT=1` to print the exact escaped text and UTF-8 bytes sent for text-carrying Lean probes - use `lean-beam --version` for bug reports and installed runtime identity checks - validated-toolchains lists exact CI-validated versions; compatible-release-lines lists canonical RC/patch families qualified locally @@ -127,7 +134,7 @@ prefix=() cmd="" while [ "$#" -gt 0 ]; do case "$1" in - --root|--port) + --root|--control-dir|--port) [ "$#" -ge 2 ] || break prefix+=("$1" "$2") shift 2 diff --git a/scripts/lean-beam-search b/scripts/lean-beam-search index 2c742b82..9df564b0 100755 --- a/scripts/lean-beam-search +++ b/scripts/lean-beam-search @@ -45,7 +45,7 @@ usage: notes: - branch, linear, playout, and release read a prior wrapper response or handle JSON from stdin - mint requires the version returned by `lean-beam update ` or `lean-beam sync ` - - lean-beam opts such as --root and --port may appear before the subcommand + - lean-beam opts such as --root may appear before the subcommand EOF exit 1 } diff --git a/skills/lean-beam/SKILL.md b/skills/lean-beam/SKILL.md index 0ca239f1..ccd33d98 100644 --- a/skills/lean-beam/SKILL.md +++ b/skills/lean-beam/SKILL.md @@ -73,8 +73,8 @@ and isolation rules; do not treat MCP as a raw Lean LSP proxy. Supported command families: -- bootstrap the Lean backend: `lean-beam ensure` -- keep a sandboxed daemon owner alive across separate commands: `lean-beam ensure --hold` +- start and own a wrapper session: `lean-beam ensure --hold` +- check and warm an already-owned Lean session: `lean-beam ensure` - inspect existing code, navigation data, or proof state: `lean-beam hover`, `lean-beam signature-help`, `lean-beam definition`, `lean-beam references`, `lean-beam document-symbols`, `lean-beam workspace-symbols`, `lean-beam goals before`, @@ -108,8 +108,10 @@ Core workflow contract: - use `lean-beam`, not raw JSON and not raw LSP - Beam never applies source edits to `.lean` files on disk; the client applies source edits - `lean-beam` only sees the on-disk file, not unsaved editor buffers -- in transient PID-sandboxed command runners, start one foreground `lean-beam ensure --hold` - process when you need daemon reuse across separate shell invocations; interrupt it when finished +- before using wrapper workflow commands, start one foreground `lean-beam ensure --hold` process + and keep it running across shell invocations; interrupt it or run `lean-beam shutdown` when finished +- MCP owns its stdio runtime session automatically; do not start a separate wrapper holder solely + for MCP tool calls - after every real Lean source edit: save the file normally, then run `lean-beam update` before the next version-bound probe; run `lean-beam sync` when you need diagnostics/readiness - use `lean-beam save` only for a synced workspace module path in the current Lake workspace package @@ -124,8 +126,12 @@ Core workflow contract: to `leanOptions`, or use `lake build` when the arguments are intentionally batch-only - after changing a lakefile or related Lake workspace configuration, run `lean-beam shutdown` before the next command that uses the Lean server; `lean-beam refresh` does not restart it -- treat wrapper `stderr` as human-facing only; use stdout JSON or `beam-client request-stream` +- treat wrapper `stderr` as human-facing only; use stdout JSON or + `lean-beam --root ROOT request-stream` for machine-readable automation +- for a machine-readable wrapper stream, keep `lean-beam ensure --hold` active and use + `lean-beam --root ROOT request-stream`; raw `beam-client --port` requests are maintainer tooling + for separately managed brokers - `lean-beam feedback-report` and `beam_feedback_report` return a report to the caller; Beam does not upload or submit it; before posting non-confidential output, review caller-authored narrative, request/response payloads, local paths, Beam stats, open-file data, daemon logs/incidents, and @@ -288,28 +294,41 @@ Use `lean-beam`, not raw JSON and not raw LSP. - infers the target project root from the current directory or `--root` - keeps one Beam daemon per project root and records it in `/.beam/beam-daemon.json` - - in sandboxed or read-only project trees, set `BEAM_CONTROL_DIR` to a writable directory; `lean-beam` uses a per-root subdirectory there + - in sandboxed or read-only project trees, set `BEAM_CONTROL_ROOT` to a writable directory; + `lean-beam` uses a per-root subdirectory there + - for an exact stable alternate session location, pass the same `--control-dir DIR` to the owner + and every attaching command; Beam does not search alternate control directories - resolves a toolchain-keyed Lean bundle, preferring the installed beam bundle cache and falling back to a project-local runtime bundle under `/.beam/bundles` or `BEAM_BUNDLE_DIR` - fully validates exact Lean toolchains listed in `validated-lean-toolchains`, locally qualifies canonical RC/patch variants from `compatible-lean-release-lines`, and accepts exact custom names recorded by the installer in `custom-lean-toolchains` -- owns Beam daemon startup, shutdown, and registry handling +- gives daemon startup authority only to `lean-beam ensure --hold`; ordinary wrapper commands attach + to its registry generation and never start a daemon implicitly +- owns shutdown and registry handling - resolves Lean with `elan which lean` - builds and plugin-qualifies a local fallback bundle only when no matching installed bundle exists for the exact accepted toolchain fingerprint - fails early on toolchains that are neither validated, canonical members of a compatible release line, nor explicitly custom; use `lean-beam validated-toolchains`, `lean-beam compatible-release-lines`, and `lean-beam doctor` to inspect the decision -- restarts the Beam daemon if the effective Lean startup configuration for that root changes +- after effective Lean startup configuration changes, requires shutting down the old session and + starting a new `lean-beam ensure --hold` owner +- after abnormal owner/broker exit, preserves the session fence until explicit + `recover --generation ID`; recovery quarantines metadata and never signals its persisted PIDs - `lean-beam shutdown`, `lean-beam stats`, and `lean-beam reset-stats` apply to the current project only - `lean-beam prune` previews old installed runtimes; restart active agents and MCP clients before any `--apply`, and add `--bundles` when stale installed bundle caches should also be removed - wrapper commands talk to the per-project Beam daemon over localhost TCP; they are not direct in-process Lean calls -- `lean-beam ensure --hold` prints the usual JSON ensure response on stdout, keeps the wrapper - process alive until interrupted, and is only for environments that reap background daemons when - each command exits; later wrappers recover from same-namespace stale lease files left by killed - wrapper processes +- `lean-beam ensure --hold` prints the usual JSON ensure response on stdout and keeps the wrapper + process alive as the session owner; an inherited pipe ties daemon lifetime to that process without + heartbeat files or lease expiry +- `--port` is an optional owner-start override for `lean-beam ensure --hold`; attaching commands + reject it instead of silently ignoring it +- interrupting or killing the holder closes the owner pipe and shuts down the daemon; explicit + `lean-beam shutdown` releases the holder cleanly +- plain `lean-beam ensure` checks and warms a live owned session; if there is no owner, follow its + error and start `lean-beam ensure --hold` `lean-beam` is more than a one-shot probe: @@ -328,8 +347,8 @@ Use `lean-beam`, not raw JSON and not raw LSP. Default rules: - use `lean-beam`, not raw JSON and not raw LSP +- start and keep one `lean-beam ensure --hold` owner before issuing wrapper workflow commands - start with `lean-beam run-at` -- use `lean-beam ensure --hold` only when your command runner needs a foreground owner for daemon reuse - after every real source edit: save the file to disk normally, then `lean-beam update` before the next version-bound probe; use `lean-beam sync` for diagnostics/readiness - if exact continuation matters: mint a handle @@ -342,11 +361,10 @@ Default rules: If you only remember one workflow, use this one: ```bash -lean-beam ensure -# in PID-isolated command runners, keep this in one foreground session instead +# terminal or long-lived agent process 1: keep this running lean-beam ensure --hold -# inspect existing code or proof state +# terminal or agent process 2: inspect existing code or proof state update_out="$(lean-beam update "Foo.lean")" printf '%s\n' "$update_out" version="$(printf '%s\n' "$update_out" | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["version"])')" @@ -431,7 +449,7 @@ Surface rule: - wrapper `stderr` is the human-facing diagnostic surface - wrapper `stderr` may distinguish request-level failures from a completed request whose payload failed inside Lean; use stdout JSON for machine decisions -- `beam-client request-stream ...` is the machine-facing streamed surface +- `lean-beam --root ROOT request-stream ...` is the supported machine-facing wrapper stream - do not parse wrapper `stderr` in tooling - MCP clients can attach `tools/call` `_meta.progressToken` for detailed live updates; without one, Beam keeps fast broker-backed Lean operations, feedback collection, and workspace drops quiet and @@ -456,7 +474,7 @@ Use this when you are deciding between commands: - human after a real saved edit: `lean-beam sync` - human checkpointing one synced module: `lean-beam save` or `lean-beam close-save` - human diagnosing daemon or save-state trouble: `lean-beam open-files` and `lean-beam doctor` -- tooling that wants streamed diagnostics or progress: `beam-client request-stream ...` +- tooling that wants streamed diagnostics or progress: `lean-beam --root ROOT request-stream ...` ## References diff --git a/skills/lean-beam/agents/openai.yaml b/skills/lean-beam/agents/openai.yaml index fc5e080f..9d4c3f6b 100644 --- a/skills/lean-beam/agents/openai.yaml +++ b/skills/lean-beam/agents/openai.yaml @@ -5,7 +5,7 @@ interface: display_name: "Lean Beam" short_description: "Lean probes, sync, and exact chaining" - default_prompt: "Use $lean-beam on an external Lean project for isolated on-disk Lean probes through beam; use lean-beam sync after real edits, use lean-beam ensure --hold only when a sandbox needs daemon reuse across commands, do not assume separate lean-beam run-at calls chain, and use pre-stable handle workflows only when exact speculative continuation matters." + default_prompt: "Use $lean-beam on an external Lean project for isolated on-disk Lean probes through beam; start lean-beam ensure --hold and keep it running while using wrapper commands, use lean-beam sync after real edits, do not assume separate lean-beam run-at calls chain, and use pre-stable handle workflows only when exact speculative continuation matters." policy: allow_implicit_invocation: true diff --git a/skills/lean-beam/references/anti-patterns.md b/skills/lean-beam/references/anti-patterns.md index 627273dc..dbbfcda5 100644 --- a/skills/lean-beam/references/anti-patterns.md +++ b/skills/lean-beam/references/anti-patterns.md @@ -29,7 +29,7 @@ Use this reference as a short checklist of what not to assume in Lean `beam` wor - use `lean-beam run-at-handle` plus `lean-beam run-with` / `lean-beam run-with-linear` for exact speculative chaining - use a real edit, save, then `lean-beam sync` when the speculative result should become source - use `lean-beam save` only for a synced workspace module -- use `beam-client request-stream` for machine-readable streaming diagnostics or progress +- use `lean-beam --root ROOT request-stream` for machine-readable streaming diagnostics or progress - use `lake build` when the task has become dependency freshness - rely on successful Beam checkpoints for ordinary local development, ensure CI runs `lake build` from clean artifacts, and use local `lean-beam shutdown`, `lake clean`, and `lake build` once only diff --git a/skills/lean-beam/references/mcts-search.md b/skills/lean-beam/references/mcts-search.md index 4c83b171..eded285f 100644 --- a/skills/lean-beam/references/mcts-search.md +++ b/skills/lean-beam/references/mcts-search.md @@ -36,7 +36,7 @@ Rules: ## Minimal branching example ```bash -lean-beam ensure +# with `lean-beam ensure --hold` running in another process root="$(lean-beam run-at-handle "Proofs.lean" 42 6 "constructor")" # writing handles to files avoids stdin conflicts in larger shell scripts @@ -57,7 +57,7 @@ Use this when you want to explore multiple children from the same preserved basi ## Minimal linear playout example ```bash -lean-beam ensure +# with `lean-beam ensure --hold` running in another process root="$(lean-beam run-at-handle "Proofs.lean" 42 6 "constructor")" # file-backed handles are often easier in longer shell loops printf '%s\n' "$root" > root.handle.json @@ -87,7 +87,7 @@ Use this when you want one evolving playout path instead of a preserved branch p Concrete shell sketch: ```bash -lean-beam ensure +# with `lean-beam ensure --hold` running in another process root="$(lean-beam run-at-handle "Proofs.lean" 42 6 "constructor")" child_a="$(printf '%s\n' "$root" | lean-beam run-with "Proofs.lean" - "constructor")" @@ -102,7 +102,7 @@ printf '%s\n' "$child_b" | lean-beam release "Proofs.lean" - The same sketch with the helper: ```bash -lean-beam ensure +# with `lean-beam ensure --hold` running in another process root="$(lean-beam-search mint "Proofs.lean" 42 6 "constructor")" child_a="$(printf '%s\n' "$root" | lean-beam-search branch "Proofs.lean" "constructor")" child_b="$(printf '%s\n' "$root" | lean-beam-search branch "Proofs.lean" "aesop")" diff --git a/skills/lean-beam/references/workflow-details.md b/skills/lean-beam/references/workflow-details.md index 7f1fc5bf..7d6cf42c 100644 --- a/skills/lean-beam/references/workflow-details.md +++ b/skills/lean-beam/references/workflow-details.md @@ -188,7 +188,7 @@ What is not a valid checkpoint target: `error.message` includes a compact preview of underlying diagnostics and/or command messages, and `error.data.sync` contains the blocking sync verdict - wrapper `stderr` is the human-facing diagnostic surface -- `beam-client request-stream ...` is the machine-facing streamed surface +- `lean-beam --root ROOT request-stream ...` is the supported machine-facing wrapper stream - streamed diagnostics are request-scoped observations; they may carry `completionBlocking=true`, but save-blocking evidence is attached to the final sync/save verdict - the field-level progress, diagnostic, and readiness contract lives in @@ -210,7 +210,7 @@ What is not a valid checkpoint target: Treat `fileProgress` as observability, not as proof that every call is a full barrier. ```bash -lean-beam ensure +# with `lean-beam ensure --hold` running in another process sync_out="$(lean-beam sync "Foo.lean")" printf '%s\n' "$sync_out" version="$(printf '%s\n' "$sync_out" | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["version"])')" @@ -308,7 +308,7 @@ If you edit `A.lean` and `B.lean` imports `A.lean`, a successful probe in `B.lea itself to prove the dependency cone is fresh. ```bash -lean-beam ensure +# with `lean-beam ensure --hold` running in another process # make a real edit in A.lean and save the source file to disk lean-beam sync "A.lean" b_update="$(lean-beam update "B.lean")" diff --git a/skills/rocq-beam/SKILL.md b/skills/rocq-beam/SKILL.md index bace5acc..f570b326 100644 --- a/skills/rocq-beam/SKILL.md +++ b/skills/rocq-beam/SKILL.md @@ -52,7 +52,8 @@ mutation. Supported command families: -- bootstrap the Rocq backend: `lean-beam ensure rocq` +- start and own a Rocq wrapper session: `lean-beam ensure rocq --hold` +- check and warm an already-owned Rocq session: `lean-beam ensure rocq` - inspect goals after an existing sentence: `lean-beam rocq-goals-after` - inspect goals before a sentence or after speculative sentence text within that basis: `lean-beam rocq-goals-prev` @@ -67,6 +68,8 @@ What to treat as the current agent workflow surface: Core workflow contract: - use `lean-beam`, not raw JSON and not raw LSP +- before issuing wrapper probes, start one foreground `lean-beam ensure rocq --hold` process and + keep it running; interrupt it or run `lean-beam shutdown` when finished - save the `.v` file before every new probe after a real edit - `lean-beam` only sees the on-disk file, not unsaved editor buffers - treat ` ` as LSP-style coordinates for the saved file: line `0` is the first @@ -84,10 +87,17 @@ Use `lean-beam`, not raw JSON and not raw LSP. - infers the target project root from the current directory or `--root` - keeps one Beam daemon per project root and records it in `/.beam/beam-daemon.json` - - in sandboxed or read-only project trees, set `BEAM_CONTROL_DIR` to a writable directory -- owns Beam daemon startup, shutdown, and registry handling + - in sandboxed or read-only project trees, set `BEAM_CONTROL_ROOT` to a writable directory + - for an exact stable alternate location, pass the same `--control-dir DIR` to the owner and every + attaching command; Beam does not search alternate control directories +- gives daemon startup authority only to `lean-beam ensure rocq --hold`; ordinary commands attach + to its registry generation and never start a daemon implicitly +- owns shutdown and registry handling +- preserves ambiguous crash state until explicit `recover --generation ID`; recovery does not + signal persisted PIDs - resolves `coq-lsp` from the target project's local `_opam` when available -- starts a Rocq-capable Beam daemon with explicit startup args instead of relying on inherited editor state +- the explicit owner starts a Rocq-capable Beam daemon with startup args instead of relying on + inherited editor state - wrapper commands talk to the per-project Beam daemon over localhost TCP; they are not direct in-process Rocq calls - in Codex-style sandboxes, Beam daemon startup may still require elevated permissions even when all paths resolve correctly - in the same environments, localhost TCP bind/connect for the Beam daemon and client may also require elevated permissions @@ -106,10 +116,14 @@ Default rules: ## Workflow -Ensure the Rocq backend: +Start the Rocq wrapper-session owner in one terminal or long-lived agent process, then inspect it +from another: ```bash -lean-beam ensure rocq +# terminal/session 1: keep running +lean-beam ensure rocq --hold + +# terminal/session 2 lean-beam stats ``` @@ -150,7 +164,7 @@ Execution model: Default loop: ```bash -lean-beam ensure rocq +# with `lean-beam ensure rocq --hold` running in another process lean-beam rocq-goals-after "Demo.v" 12 4 # make a real edit, save the file @@ -162,14 +176,14 @@ Use cases: 1. Inspect the current proof state after a sentence ```bash -lean-beam ensure rocq +# with `lean-beam ensure rocq --hold` running in another process lean-beam rocq-goals-after "Demo.v" 12 4 ``` 2. Inspect an intermediate tactic state inside one sentence ```bash -lean-beam ensure rocq +# with `lean-beam ensure rocq --hold` running in another process lean-beam rocq-goals-prev "Demo.v" 12 4 "intro x." lean-beam rocq-goals-prev "Demo.v" 12 4 "split." ``` @@ -179,7 +193,7 @@ lean-beam rocq-goals-prev "Demo.v" 12 4 "split." Save the file first, then probe again from the saved document. ```bash -lean-beam ensure rocq +# with `lean-beam ensure rocq --hold` running in another process lean-beam rocq-goals-after "Demo.v" 12 4 # make a real edit in Demo.v and save it diff --git a/skills/rocq-beam/agents/openai.yaml b/skills/rocq-beam/agents/openai.yaml index 0595e2dd..1da4eb9b 100644 --- a/skills/rocq-beam/agents/openai.yaml +++ b/skills/rocq-beam/agents/openai.yaml @@ -5,7 +5,7 @@ interface: display_name: "Rocq Beam Aux" short_description: "Optional Rocq-to-Lean goal probes" - default_prompt: "Use $rocq-beam when an external Rocq project needs the optional Rocq goal-probe surface exposed through lean-beam for Rocq-to-Lean porting; save before probing, use lean-beam rocq-goals-after / rocq-goals-prev, and do not assume hidden cross-request proof state, handles, unsaved-buffer access, or a Rocq run-at command." + default_prompt: "Use $rocq-beam when an external Rocq project needs the optional Rocq goal-probe surface exposed through lean-beam for Rocq-to-Lean porting; start lean-beam ensure rocq --hold and keep it running while using wrapper commands, save before probing, use lean-beam rocq-goals-after / rocq-goals-prev, and do not assume hidden cross-request proof state, handles, unsaved-buffer access, or a Rocq run-at command." policy: allow_implicit_invocation: true diff --git a/tests/lean/BeamTest/Broker/CliDaemonTest.lean b/tests/lean/BeamTest/Broker/CliDaemonTest.lean index db769c66..7db8ecc8 100644 --- a/tests/lean/BeamTest/Broker/CliDaemonTest.lean +++ b/tests/lean/BeamTest/Broker/CliDaemonTest.lean @@ -12,6 +12,7 @@ import Beam.Cli.LeanOperation import Beam.Cli.Lock import Beam.Cli.RuntimeBundle import Beam.Daemon.Debug +import Beam.Daemon.Paths import Beam.Path import BeamTest.Broker.JsonAssert @@ -24,21 +25,14 @@ private def require (label : String) (cond : Bool) : IO Unit := do unless cond do throw <| IO.userError label -private def checkDaemonDebugWarnings : IO Unit := do - let debug := Json.mkObj [ - ("registry", Json.mkObj [ - ("daemonId", toJson "fixture-daemon"), - ("pid", toJson (424242 : Nat)) - ]), - ("registryPidStatus", toJson "not alive"), - ("registryEndpoint", toJson "tcp://127.0.0.1:42424") - ] - let warnings := Beam.Daemon.daemonDebugWarnings debug - require "dead registry pid should produce a feedback warning" - (warnings.any (fun warning => warning.contains "registry pid is not alive")) - require "dead registry pid warning should include recovery hint" - (warnings.any (fun warning => - warning.contains "lean-beam shutdown" && warning.contains "lean-beam ensure")) +private def projectDaemonClientForTest + (endpoint : Beam.Broker.Transport.Endpoint) + (controlDir : System.FilePath) : Beam.Cli.ProjectDaemonClient := { + endpoint + capability := "test-capability" + workspaceId := Beam.Cli.projectDaemonWorkspaceId + controlDir +} private def expectIoErrorMessage (label : String) (act : IO α) : IO String := do let result ← @@ -60,6 +54,9 @@ private def expectIoErrorContains (label needle : String) (act : IO α) : IO Uni private def requireSubstring (label needle haystack : String) : IO Unit := do require s!"{label}: expected '{needle}' in '{haystack}'" (Beam.Cli.hasSubstring haystack needle) +private def brokerTransportFailure (detail : String) : Beam.Broker.BrokerClientFailure := + .transport .receive (IO.userError detail) + private def requireJsonNat (label field : String) (expected : Nat) (json : Json) : IO Unit := do let actual ← IO.ofExcept <| json.getObjValAs? Nat field require s!"{label}: expected {field}={expected}, got {actual}" (actual == expected) @@ -68,11 +65,8 @@ private def requireJsonStringContains (label field needle : String) (json : Json let actual ← IO.ofExcept <| json.getObjValAs? String field require s!"{label}: expected {field} to contain {needle}, got {actual}" (actual.contains needle) -private def daemonFailureIncidentDir (root : System.FilePath) : IO System.FilePath := do - pure ((← Beam.Cli.controlDir root) / "daemon-failures") - private def sortedIncidentEntries (root : System.FilePath) : IO (Array IO.FS.DirEntry) := do - let dir ← daemonFailureIncidentDir root + let dir ← Beam.Daemon.daemonFailureIncidentDir root unless ← dir.pathExists do return #[] let entries ← dir.readDir @@ -80,7 +74,7 @@ private def sortedIncidentEntries (root : System.FilePath) : IO (Array IO.FS.Dir (fun a b => a.fileName < b.fileName) private def readSingleDaemonFailureIncidentJson (root : System.FilePath) : IO Json := do - let incidentDir ← daemonFailureIncidentDir root + let incidentDir ← Beam.Daemon.daemonFailureIncidentDir root require "daemon failure should write incident directory" (← incidentDir.pathExists) let incidentEntries ← sortedIncidentEntries root require s!"expected one daemon failure incident, got {incidentEntries.size}" (incidentEntries.size == 1) @@ -91,7 +85,23 @@ private def readSingleDaemonFailureIncidentJson (root : System.FilePath) : IO Js private def closeAcceptedConnection (listener : Beam.Broker.Transport.Listener) : IO Unit := do let conn ← Beam.Broker.Transport.accept listener - Beam.Broker.Transport.closeConnection conn + try + -- Read the complete request before closing so the client failure is deterministically on the + -- receive boundary. The operating system may still describe that close as EOF or ECONNRESET. + discard <| Beam.Broker.Transport.recvMsg conn + finally + Beam.Broker.Transport.closeConnection conn + +private def holdAcceptedConnection + (listener : Beam.Broker.Transport.Listener) + (release : IO.Promise Unit) : IO Unit := do + let conn ← Beam.Broker.Transport.accept listener + try + let some _ ← IO.wait release.result? + | throw <| IO.userError "silent endpoint release promise dropped" + pure () + finally + Beam.Broker.Transport.closeConnection conn private partial def withClosingBrokerEndpoint (act : Beam.Broker.Transport.Endpoint → IO α) @@ -122,6 +132,124 @@ private partial def withClosingBrokerEndpoint | .ok value => pure value | .error err => throw err +private partial def withBrokerListener + (act : Beam.Broker.Transport.Listener → Beam.Broker.Transport.Endpoint → IO α) + (tries : Nat := 20) : IO α := do + let stamp ← IO.monoNanosNow + let portNat := 30000 + ((stamp + tries) % 20000) + let endpoint := Beam.Broker.Transport.Endpoint.tcp portNat.toUInt16 + let listenerResult ← + try + pure <| Except.ok (← Beam.Broker.Transport.bindAndListen endpoint 2) + catch err => + pure <| Except.error err + match listenerResult with + | .error err => + if tries == 0 then + throw err + else + withBrokerListener act (tries - 1) + | .ok listener => + act listener endpoint + +private def checkSilentEndpointProbeTimeout : IO Unit := do + withBrokerListener fun listener endpoint => do + let release ← IO.Promise.new + let serverTask ← IO.asTask (prio := Task.Priority.dedicated) <| + holdAcceptedConnection listener release + try + let identity : Beam.Broker.DaemonIdentity := { + daemonId := "silent-endpoint" + configHash := "silent-endpoint" + } + match ← Beam.Daemon.daemonGenerationStatus endpoint + Beam.Cli.projectDaemonWorkspaceId (System.FilePath.mk "/tmp") identity + "test-capability" with + | .unrecognized (.responseTimeout timeoutMs) => + require "silent endpoint should preserve its typed response timeout" + (timeoutMs == 2000) + | .unrecognized failure => + throw <| IO.userError s!"silent endpoint reported {repr failure}" + | status => + throw <| IO.userError s!"silent endpoint was classified as {repr status}" + finally + release.resolve () + match ← IO.wait serverTask with + | .ok () => pure () + | .error err => throw err + +private def checkSilentShutdownTimeout : IO Unit := do + withBrokerListener fun listener endpoint => do + let release ← IO.Promise.new + let serverTask ← IO.asTask (prio := Task.Priority.dedicated) <| + holdAcceptedConnection listener release + try + match ← Beam.Cli.requestDaemonShutdown endpoint "test-capability" 50 with + | .error (.responseTimeout timeoutMs) => + require "silent shutdown should preserve its typed response timeout" (timeoutMs == 50) + | .error failure => + throw <| IO.userError s!"silent shutdown reported {repr failure}" + | .ok response => + throw <| IO.userError s!"silent shutdown returned {toJson response}" + finally + release.resolve () + match ← IO.wait serverTask with + | .ok () => pure () + | .error err => throw err + +private def serveCancelablePlainRequest + (listener : Beam.Broker.Transport.Listener) + (requestObserved : IO.Promise Unit) : IO Unit := do + let requestConn ← Beam.Broker.Transport.accept listener + try + let requestText ← Beam.Broker.Transport.recvMsg requestConn + let requestJson ← IO.ofExcept <| Json.parse requestText + let request : Beam.Broker.Request ← IO.ofExcept <| fromJson? requestJson + let some requestId := request.clientRequestId? + | throw <| IO.userError "plain wrapper request omitted its synthesized clientRequestId" + requestObserved.resolve () + let cancelConn ← Beam.Broker.Transport.accept listener + try + let cancelText ← Beam.Broker.Transport.recvMsg cancelConn + let cancelJson ← IO.ofExcept <| Json.parse cancelText + let cancelRequest : Beam.Broker.Request ← IO.ofExcept <| fromJson? cancelJson + unless cancelRequest.op == .cancel && cancelRequest.cancelRequestId? == some requestId do + throw <| IO.userError "plain wrapper cancellation did not target the admitted request" + let cancelResponse := Beam.Broker.Response.success <| + Json.mkObj [("cancelled", toJson true)] + Beam.Broker.Transport.sendMsg cancelConn + (toJson (Beam.Broker.StreamMessage.response + cancelRequest.clientRequestId? cancelResponse)).compress + finally + Beam.Broker.Transport.closeConnection cancelConn + let response := Beam.Broker.errorResponseFor + .requestCancelled "cancelled by session close" + Beam.Broker.Transport.sendMsg requestConn + (toJson (Beam.Broker.StreamMessage.response (some requestId) response)).compress + finally + Beam.Broker.Transport.closeConnection requestConn + +private def checkPlainBrokerTaskCancellation : IO Unit := do + withBrokerListener fun listener endpoint => do + let requestObserved ← IO.Promise.new + let serverTask ← IO.asTask (prio := Task.Priority.dedicated) <| + serveCancelablePlainRequest listener requestObserved + let requestTask ← IO.asTask (prio := Task.Priority.dedicated) <| + Beam.Cli.requestBroker (System.FilePath.mk "/tmp") + (projectDaemonClientForTest endpoint (System.FilePath.mk "/tmp")) { op := .stats } + let some _ ← IO.wait requestObserved.result? + | throw <| IO.userError "plain wrapper request observation promise dropped" + IO.cancel requestTask + let response ← + match ← IO.wait requestTask with + | .ok response => pure response + | .error err => throw err + require "a cancelled plain wrapper request should receive broker requestCancelled" + (response.error?.any fun err => err.code == "requestCancelled") + match ← IO.wait serverTask with + | .ok () => pure () + | .error err => throw err + private def requireRequestJson (label : String) (actual expected : Beam.Broker.Request) : IO Unit := do @@ -139,20 +267,18 @@ private def sampleBrokerHandle : Beam.Broker.Handle := { } private def checkProjectDaemonWorkspaceRouting : IO Unit := do - let ensureReq := Beam.Cli.inProjectDaemonWorkspace ({ op := .ensure } : Beam.Broker.Request) - require "CLI workspace-bound requests should select the project daemon workspace" - (ensureReq.workspaceId? == some Beam.Cli.projectDaemonWorkspaceId) - let statsReq := Beam.Cli.inProjectDaemonWorkspace ({ op := .stats } : Beam.Broker.Request) - require "CLI stats should be scoped to the project daemon workspace" - (statsReq.workspaceId? == some Beam.Cli.projectDaemonWorkspaceId) - let shutdownReq := Beam.Cli.inProjectDaemonWorkspace ({ op := .shutdown } : Beam.Broker.Request) - require "process-wide CLI shutdown should remain unscoped" shutdownReq.workspaceId?.isNone - let explicitReq := Beam.Cli.inProjectDaemonWorkspace ({ - op := .ensure - workspaceId? := some "maintenance-fixture" - } : Beam.Broker.Request) - require "CLI routing should preserve an explicitly selected workspace" - (explicitReq.workspaceId? == some "maintenance-fixture") + let selectedClient : Beam.Cli.ProjectDaemonClient := { + endpoint := .tcp 42424 + capability := "test-capability" + workspaceId := "selected-workspace" + controlDir := System.FilePath.mk "/tmp/beam-selected-control" + } + let selectedCancel := Beam.Cli.inSelectedDaemonWorkspace selectedClient { + op := .cancel + cancelRequestId? := some "request" + } + require "selected descriptor workspace should scope cancellation" + (selectedCancel.workspaceId? == some "selected-workspace") private def checkClientResponsePresentation : IO Unit := do let semantic := Beam.Broker.Response.success Json.null @@ -284,6 +410,24 @@ private def checkCliRootParsing : IO Unit := do "missing explicit CLI root should use the workspace error boundary" "workspace root does not resolve" (Beam.Cli.parseCliOptions {} ["--root", missingRoot.toString, "ensure", "lean"]) + let root := System.FilePath.mk s!"/tmp/beam-cli-root-{← IO.monoNanosNow}" + let control := root / "shared-control" + try + IO.FS.createDirAll control + let expectedRoot ← Beam.resolveExistingPath root + let expectedControl ← Beam.resolveExistingPath control + let opts ← Beam.Cli.parseCliOptions {} [ + "--root", root.toString, + "--control-dir", control.toString, + "stats" + ] + require "explicit CLI root should be canonicalized" (opts.explicitRoot? == some expectedRoot) + require "explicit control directory should remain an exact selection" + (opts.explicitControlDir? == some expectedControl) + require "global selectors should not leak into command arguments" (opts.args == ["stats"]) + finally + if ← root.pathExists then + IO.FS.removeDirAll root private def checkLeanOperationRequests : IO Unit := do let root := System.FilePath.mk "/repo" @@ -434,34 +578,45 @@ private def checkStartupRetryPolicy : IO Unit := do require "explicit endpoint should not retry" (!Beam.Daemon.shouldRetryAutomaticStartup false 1 true true) require "Linux bind failure wording should be recognized" - (Beam.Daemon.startupFailureSuggestsEndpointInUse "resource busy (error code: 4294967198, address already in use)") + (Beam.Daemon.startupLogSuggestsEndpointInUse "resource busy (error code: 4294967198, address already in use)") require "macOS bind failure wording should be recognized" - (Beam.Daemon.startupFailureSuggestsEndpointInUse "Address already in use") + (Beam.Daemon.startupLogSuggestsEndpointInUse "Address already in use") private def checkDaemonFailureContext : IO Unit := do let root := System.FilePath.mk s!"/tmp/beam-daemon-failure-context-{← IO.monoNanosNow}" try IO.FS.createDirAll root - let registryPath ← Beam.Cli.registryPath root + let registryPath ← Beam.Daemon.registryPath root if let some parent := registryPath.parent then IO.FS.createDirAll parent - let entry : Beam.Daemon.RegistryEntry := { + let entry : Beam.Daemon.SessionDescriptor := { + schemaVersion := Beam.Daemon.registrySchemaVersion + lifecycle := .live daemonId := "daemon-test" + capability := "test-capability" pid := 999999999 + ownerPid := 999999999 port? := some 42424 - root := root.toString + workspaces := #[{ + workspaceId := Beam.Cli.projectDaemonWorkspaceId + root := root.toString + configHash := "config-test" + toolchain? := some "leanprover/lean4:test" + bundleId? := some "bundle-test" + }] configHash := "config-test" - toolchain? := some "leanprover/lean4:test" - bundleId? := some "bundle-test" startedAt := "2026-07-02T00:00:00Z" } IO.FS.writeFile registryPath ((toJson entry).pretty ++ "\n") - let startupLog := (← Beam.Cli.controlDir root) / "beam-daemon-startup.log" + let startupLog ← Beam.Daemon.daemonStartupLogPath root IO.FS.writeFile startupLog "line 1\nline 2\n" - let msg ← Beam.Cli.daemonFailureMessage root "Beam daemon connection closed" + let detail := "synthetic broker transport failure" + let failure := brokerTransportFailure detail + let msg ← Beam.Cli.daemonFailureMessage root failure requireSubstring "daemon failure context should include registry path" "Beam daemon registry" msg requireSubstring "daemon failure context should include daemon id" "daemonId: daemon-test" msg - requireSubstring "daemon failure context should include dead pid status" "pid: 999999999 (not alive)" msg + requireSubstring "daemon failure context should mark persisted pids as diagnostic" + "pid: 999999999 (diagnostic only)" msg requireSubstring "daemon failure context should include endpoint" "endpoint: tcp://127.0.0.1:42424" msg requireSubstring "daemon failure context should include toolchain" "toolchain: leanprover/lean4:test" msg requireSubstring "daemon failure context should include bundle id" "bundleId: bundle-test" msg @@ -471,20 +626,20 @@ private def checkDaemonFailureContext : IO Unit := do let incidentJson ← readSingleDaemonFailureIncidentJson root requireJsonNat "daemon failure incident should use schema version" "schemaVersion" 1 incidentJson - requireJsonString "daemon failure incident should classify connection close" - "kind" "connectionClosed" incidentJson - requireJsonString "daemon failure incident should keep original detail" - "detail" "Beam daemon connection closed" incidentJson + requireJsonString "daemon failure incident should classify the typed transport failure" + "kind" "brokerTransportFailure" incidentJson + requireJsonString "daemon failure incident should retain typed operation context" + "detail" failure.detail incidentJson requireJsonString "daemon failure incident should include root" "root" root.toString incidentJson requireJsonString "daemon failure incident should include registry path" "registryPath" registryPath.toString incidentJson let incidentRegistryJson ← IO.ofExcept <| incidentJson.getObjVal? "registry" - let incidentRegistry ← IO.ofExcept <| fromJson? (α := Beam.Daemon.RegistryEntry) incidentRegistryJson + let incidentRegistry ← IO.ofExcept <| fromJson? (α := Beam.Daemon.SessionDescriptor) incidentRegistryJson require "daemon failure incident should include daemon id" (incidentRegistry.daemonId == "daemon-test") - requireJsonString "daemon failure incident should include registry pid status" - "registryPidStatus" "not alive" incidentJson + require "daemon failure incident must redact the per-generation capability" + (incidentRegistry.capability == "") requireJsonString "daemon failure incident should include endpoint summary" "registryEndpoint" "tcp://127.0.0.1:42424" incidentJson requireJsonString "daemon failure incident should include startup log path" @@ -493,7 +648,7 @@ private def checkDaemonFailureContext : IO Unit := do "startupLogTail" "line 1\nline 2" incidentJson finally try - let control ← Beam.Cli.controlDir root + let control ← Beam.Daemon.controlDir root if ← control.pathExists then IO.FS.removeDirAll control catch _ => @@ -504,25 +659,31 @@ private def checkDaemonFailureContext : IO Unit := do catch _ => pure () -private def checkNoLiveDaemonFailureIncident : IO Unit := do - let root := System.FilePath.mk s!"/tmp/beam-no-live-daemon-incident-{← IO.monoNanosNow}" +private def checkDaemonFailureUnreadableStartupLog : IO Unit := do + let root := System.FilePath.mk s!"/tmp/beam-daemon-unreadable-startup-log-{← IO.monoNanosNow}" try IO.FS.createDirAll root - let detail := s!"no live Beam daemon registered for {root}" - let msg ← Beam.Cli.daemonFailureMessage root detail - requireSubstring "no-live daemon failure should include incident path" "Beam daemon incident:" msg + let startupLog ← Beam.Daemon.daemonStartupLogPath root + IO.FS.createDirAll startupLog + let msg ← Beam.Cli.daemonFailureMessage root <| + brokerTransportFailure "Beam daemon connection closed" + requireSubstring "unreadable startup log should preserve original daemon failure" + "Beam daemon connection closed" msg + requireSubstring "unreadable startup log should still write incident path" + "Beam daemon incident:" msg + require "unreadable startup log should not print daemon log tail" + (!Beam.Cli.hasSubstring msg "Beam daemon log tail") let incidentJson ← readSingleDaemonFailureIncidentJson root - requireJsonNat "no-live daemon incident should use schema version" "schemaVersion" 1 incidentJson - requireJsonString "no-live daemon incident should classify stale lookup" - "kind" "noLiveDaemon" incidentJson - requireJsonString "no-live daemon incident should keep original detail" - "detail" detail incidentJson - requireJsonString "no-live daemon incident should include root" - "root" root.toString incidentJson + requireJsonString "unreadable startup log incident should classify transport failure" + "kind" "brokerTransportFailure" incidentJson + requireJsonNull "unreadable startup log incident should omit startup log path" + "startupLogPath" incidentJson + requireJsonNull "unreadable startup log incident should omit startup log tail" + "startupLogTail" incidentJson finally try - let control ← Beam.Cli.controlDir root + let control ← Beam.Daemon.controlDir root if ← control.pathExists then IO.FS.removeDirAll control catch _ => @@ -533,30 +694,29 @@ private def checkNoLiveDaemonFailureIncident : IO Unit := do catch _ => pure () -private def checkDaemonFailureUnreadableStartupLog : IO Unit := do - let root := System.FilePath.mk s!"/tmp/beam-daemon-unreadable-startup-log-{← IO.monoNanosNow}" +private def checkTypedDaemonFailureClassification : IO Unit := do + let root := System.FilePath.mk s!"/tmp/beam-daemon-typed-failure-{← IO.monoNanosNow}" try IO.FS.createDirAll root - let startupLog := (← Beam.Cli.controlDir root) / "beam-daemon-startup.log" - IO.FS.createDirAll startupLog - let msg ← Beam.Cli.daemonFailureMessage root "Beam daemon connection closed" - requireSubstring "unreadable startup log should preserve original daemon failure" - "Beam daemon connection closed" msg - requireSubstring "unreadable startup log should still write incident path" - "Beam daemon incident:" msg - require "unreadable startup log should not print daemon log tail" - (!Beam.Cli.hasSubstring msg "Beam daemon log tail") - + let callbackDetail := "synthetic stream callback failure" + let callbackMsg ← Beam.Cli.daemonFailureMessage root <| + .streamCallback (IO.userError callbackDetail) + require "stream callback failure should preserve its detail" (callbackMsg == callbackDetail) + require "stream callback failure should not create a daemon incident" + (← sortedIncidentEntries root).isEmpty + + let invalidDetail := "synthetic invalid response" + let invalidMsg ← Beam.Cli.daemonFailureMessage root <| + .invalidResponse invalidDetail + requireSubstring "invalid response should include incident path" "Beam daemon incident:" invalidMsg let incidentJson ← readSingleDaemonFailureIncidentJson root - requireJsonString "unreadable startup log incident should classify connection close" - "kind" "connectionClosed" incidentJson - requireJsonNull "unreadable startup log incident should omit startup log path" - "startupLogPath" incidentJson - requireJsonNull "unreadable startup log incident should omit startup log tail" - "startupLogTail" incidentJson + requireJsonString "invalid response incident should retain its typed classification" + "kind" "invalidBrokerResponse" incidentJson + requireJsonString "invalid response incident should retain its detail" + "detail" invalidDetail incidentJson finally try - let control ← Beam.Cli.controlDir root + let control ← Beam.Daemon.controlDir root if ← control.pathExists then IO.FS.removeDirAll control catch _ => @@ -570,21 +730,101 @@ private def checkDaemonFailureUnreadableStartupLog : IO Unit := do private def writeTestRegistryEntry (root : System.FilePath) (port? : Option Nat := none) : IO Unit := do - let registryPath ← Beam.Cli.registryPath root + let registryPath ← Beam.Daemon.registryPath root if let some parent := registryPath.parent then IO.FS.createDirAll parent - let entry : Beam.Daemon.RegistryEntry := { + let entry : Beam.Daemon.SessionDescriptor := { + schemaVersion := Beam.Daemon.registrySchemaVersion + lifecycle := .live daemonId := "daemon-test" + capability := "test-capability" pid := 999999999 + ownerPid := 999999999 port? - root := root.toString + workspaces := #[{ + workspaceId := Beam.Cli.projectDaemonWorkspaceId + root := root.toString + configHash := "config-test" + toolchain? := some "leanprover/lean4:test" + bundleId? := some "bundle-test" + }] configHash := "config-test" - toolchain? := some "leanprover/lean4:test" - bundleId? := some "bundle-test" startedAt := "2026-07-05T00:00:00Z" } IO.FS.writeFile registryPath ((toJson entry).pretty ++ "\n") +private def checkTypedRegistryReads : IO Unit := do + let root := System.FilePath.mk s!"/tmp/beam-typed-registry-test-{← IO.monoNanosNow}" + try + IO.FS.createDirAll root + let registryPath ← Beam.Daemon.registryPath root + if let some parent := registryPath.parent then + IO.FS.createDirAll parent + + match ← Beam.Daemon.readRegistry root with + | .absent => pure () + | state => throw <| IO.userError s!"missing registry was classified as {state.status}" + + IO.FS.writeFile registryPath "{\"daemonId\":\"legacy\"}\n" + match ← Beam.Daemon.readRegistry root with + | .legacy => pure () + | state => throw <| IO.userError s!"legacy registry was classified as {state.status}" + + IO.FS.writeFile registryPath "{\"schemaVersion\":999}\n" + match ← Beam.Daemon.readRegistry root with + | .unsupported 999 => pure () + | state => throw <| IO.userError s!"unsupported registry was classified as {state.status}" + + IO.FS.writeFile registryPath "{\"schemaVersion\":\"one\"}\n" + match ← Beam.Daemon.readRegistry root with + | .malformed detail => + require "mistyped registry schemaVersion should be malformed" + (detail.contains "invalid registry schemaVersion") + | state => throw <| IO.userError s!"mistyped registry version was classified as {state.status}" + + IO.FS.writeFile registryPath "{" + match ← Beam.Daemon.readRegistry root with + | .malformed detail => + require "malformed registry should preserve parse context" (detail.contains "invalid registry JSON") + | state => throw <| IO.userError s!"malformed registry was classified as {state.status}" + + IO.FS.writeFile registryPath + ("{\"schemaVersion\":" ++ toString Beam.Daemon.registrySchemaVersion ++ "}\n") + match ← Beam.Daemon.readRegistry root with + | .malformed detail => + require "incomplete current registry should preserve schema context" + (detail.contains "invalid registry schema") + | state => throw <| IO.userError s!"incomplete registry was classified as {state.status}" + + writeTestRegistryEntry root + match ← Beam.Daemon.readRegistry root with + | .current entry => + require "current registry should preserve its generation capability" + (entry.capability == "test-capability") + | state => throw <| IO.userError s!"current registry was classified as {state.status}" + + let validText ← IO.FS.readFile registryPath + let validJson ← IO.ofExcept <| Json.parse validText + IO.FS.writeFile registryPath <| + (validJson.setObjVal! "workspaces" (toJson (#[] : Array Beam.Daemon.WorkspaceBinding))).compress + match ← Beam.Daemon.readRegistry root with + | .malformed detail => + require "empty workspace descriptors should fail the typed boundary" + (detail.contains "at least one workspace") + | state => throw <| IO.userError s!"empty workspace descriptor was classified as {state.status}" + + IO.FS.writeFile registryPath validText + let debug ← Beam.Daemon.daemonDebugContextJson root + let debugRegistry ← IO.ofExcept <| debug.getObjVal? "registry" + requireJsonString "daemon debug context must redact its capability" + "capability" "" debugRegistry + finally + try + if ← root.pathExists then + IO.FS.removeDirAll root + catch _ => + pure () + private def checkBrokerConnectionClosedIncident : IO Unit := do let root := System.FilePath.mk s!"/tmp/beam-broker-connection-closed-incident-{← IO.monoNanosNow}" try @@ -594,23 +834,24 @@ private def checkBrokerConnectionClosedIncident : IO Unit := do match endpoint with | .tcp port => some port.toNat writeTestRegistryEntry root port? + let controlDir ← Beam.Daemon.controlDir root let msg ← expectIoErrorMessage "broker connection close should surface daemon failure" <| - Beam.Cli.callBrokerQuiet root endpoint { op := .stats } + Beam.Cli.callBrokerQuiet root (projectDaemonClientForTest endpoint controlDir) { op := .stats } requireSubstring "broker connection close should preserve transport failure" - "Beam daemon connection closed" msg + "Beam daemon receive failed:" msg requireSubstring "broker connection close should include incident path" "Beam daemon incident:" msg let incidentJson ← readSingleDaemonFailureIncidentJson root - requireJsonString "broker close incident should classify connection close" - "kind" "connectionClosed" incidentJson + requireJsonString "broker close incident should classify the typed transport failure" + "kind" "brokerTransportFailure" incidentJson requireJsonStringContains "broker close incident should keep transport detail" - "detail" "Beam daemon connection closed" incidentJson + "detail" "Beam daemon receive failed:" incidentJson requireJsonString "broker close incident should include endpoint summary" "registryEndpoint" (Beam.Daemon.endpointSummary endpoint) incidentJson finally try - let control ← Beam.Cli.controlDir root + let control ← Beam.Daemon.controlDir root if ← control.pathExists then IO.FS.removeDirAll control catch _ => @@ -625,15 +866,16 @@ private def checkDaemonFailureIncidentRetention : IO Unit := do let root := System.FilePath.mk s!"/tmp/beam-daemon-incident-retention-{← IO.monoNanosNow}" try IO.FS.createDirAll root - let incidentDir ← daemonFailureIncidentDir root + let incidentDir ← Beam.Daemon.daemonFailureIncidentDir root IO.FS.createDirAll incidentDir for i in [0:55] do IO.FS.writeFile (incidentDir / s!"000000000000000000{i}.json") "{}\n" - let msg ← Beam.Cli.daemonFailureMessage root "Beam daemon connection closed" + let msg ← Beam.Cli.daemonFailureMessage root <| + brokerTransportFailure "Beam daemon connection closed" requireSubstring "retention failure should include incident path" "Beam daemon incident:" msg let entries ← sortedIncidentEntries root require s!"daemon incident retention should keep 50 files, got {entries.size}" (entries.size == 50) - let newIncidents := entries.filter (fun entry => entry.fileName.contains "connectionClosed") + let newIncidents := entries.filter (fun entry => entry.fileName.contains "brokerTransportFailure") require "daemon incident retention should keep newly written incident" (newIncidents.size == 1) let some newIncident := newIncidents[0]? @@ -642,7 +884,7 @@ private def checkDaemonFailureIncidentRetention : IO Unit := do (newIncident.fileName.startsWith "incident-") finally try - let control ← Beam.Cli.controlDir root + let control ← Beam.Daemon.controlDir root if ← control.pathExists then IO.FS.removeDirAll control catch _ => @@ -661,7 +903,8 @@ private def checkDoctorDaemonFailureIncidentLines : IO Unit := do require "doctor should report no daemon incidents when directory is absent" (absentLines == ["daemon incidents: none"]) - discard <| Beam.Cli.daemonFailureMessage root "Beam daemon connection closed" + discard <| Beam.Cli.daemonFailureMessage root <| + brokerTransportFailure "Beam daemon connection closed" let lines ← Beam.Cli.daemonFailureIncidentDoctorLines root require s!"doctor should report one recent daemon incident, got {lines}" (lines.head? == some "daemon incidents: 1 recent") @@ -673,7 +916,7 @@ private def checkDoctorDaemonFailureIncidentLines : IO Unit := do "daemon-failures" incidentLine finally try - let control ← Beam.Cli.controlDir root + let control ← Beam.Daemon.controlDir root if ← control.pathExists then IO.FS.removeDirAll control catch _ => @@ -781,36 +1024,22 @@ private def checkPathCanonicalization : IO Unit := do private def checkLockLifecycle : IO Unit := do let root := System.FilePath.mk s!"/tmp/beam-cli-lock-test-{← IO.monoNanosNow}" - let lockDir := root / "lock" + let lockPath := root / "lock" try - Beam.Cli.withLock lockDir do - require "lock directory should exist while lock is held" (← lockDir.pathExists) - require "lock pid file should exist while lock is held" (← (lockDir / "pid").pathExists) - require "lock directory should be removed after release" (!(← lockDir.pathExists)) - - IO.FS.createDirAll lockDir - IO.FS.writeFile (lockDir / "pid") "999999999\n" - Beam.Cli.withLock lockDir do - let pidText := (← IO.FS.readFile (lockDir / "pid")).trimAscii.toString - require "stale lock should be replaced with this process lock" (pidText != "999999999") - - IO.FS.createDirAll lockDir - let selfPid ← IO.Process.getPID - IO.FS.writeFile (lockDir / "pid") s!"{selfPid}\n" - expectIoErrorContains "live lock timeout" s!"lock owner: pid {selfPid}" <| - Beam.Cli.withLockTimeout lockDir 100 do - pure () - IO.FS.removeDirAll lockDir - - let deadPidTarget := root / "dead-pid" - IO.FS.writeFile deadPidTarget "999999999\n" - IO.FS.createDirAll lockDir - createSymlink "lock PID fixture" deadPidTarget (lockDir / "pid") - expectIoErrorContains "symlinked lock PID timeout" "lock owner: unknown owner" <| - Beam.Cli.withLockTimeout lockDir 100 do - pure () - require "a lock with a non-regular PID file should not be removed as stale" - (← lockDir.pathExists) + Beam.Cli.withLock lockPath do + require "lock file should exist while lock is held" (← lockPath.pathExists) + expectIoErrorContains "contended kernel lock timeout" "timed out after" <| + Beam.Cli.withLockTimeout lockPath 100 do + pure () + require "stable lock file should remain after release" (← lockPath.pathExists) + + Beam.Cli.withLockTimeout lockPath 100 do + require "released kernel lock should be immediately reusable" true + + Beam.Cli.withLock lockPath do + expectIoErrorContains "second contended kernel lock timeout" "timeout: 100 ms" <| + Beam.Cli.withLockTimeout lockPath 100 do + pure () finally try if ← root.pathExists then @@ -1101,11 +1330,14 @@ def main : IO Unit := do checkLeanOperationRequests checkDiagnosticScopeArgs checkStartupRetryPolicy - checkDaemonDebugWarnings checkDaemonFailureContext - checkNoLiveDaemonFailureIncident checkDaemonFailureUnreadableStartupLog + checkTypedDaemonFailureClassification + checkSilentEndpointProbeTimeout + checkSilentShutdownTimeout + checkPlainBrokerTaskCancellation checkBrokerConnectionClosedIncident + checkTypedRegistryReads checkDaemonFailureIncidentRetention checkDoctorDaemonFailureIncidentLines checkPathRelativeToRoot diff --git a/tests/lean/BeamTest/Broker/FeedbackTest.lean b/tests/lean/BeamTest/Broker/FeedbackTest.lean index 33d32aa2..8f919200 100644 --- a/tests/lean/BeamTest/Broker/FeedbackTest.lean +++ b/tests/lean/BeamTest/Broker/FeedbackTest.lean @@ -111,7 +111,6 @@ private def sampleCollection (home : String) : Beam.Feedback.Collection := { ("stats", Json.mkObj [("requests", toJson (3 : Nat))]), ("openFiles", Json.arr #[Json.mkObj [("path", toJson s!"{home}/project/Demo.lean")]]), ("daemon", Json.mkObj [ - ("registryPidStatus", toJson "alive"), ("registryEndpoint", toJson "127.0.0.1:1234"), ("recentDaemonIncidents", Json.arr #[]) ]) diff --git a/tests/lean/BeamTest/Broker/FixtureUtil.lean b/tests/lean/BeamTest/Broker/FixtureUtil.lean index 1419df94..51954318 100644 --- a/tests/lean/BeamTest/Broker/FixtureUtil.lean +++ b/tests/lean/BeamTest/Broker/FixtureUtil.lean @@ -19,7 +19,9 @@ def copySaveProjectFixture (dest : System.FilePath) : IO Unit := do IO.FS.createDirAll dest let out ← IO.Process.output { cmd := "rsync" - args := #["-a", s!"{src.toString}/", s!"{dest.toString}/"] + -- Local wrapper/install tests may leave an ignored runtime cache in this source fixture. It is + -- neither fixture input nor safe to duplicate into every isolated broker test project. + args := #["-a", "--exclude", ".beam/", s!"{src.toString}/", s!"{dest.toString}/"] } if out.exitCode != 0 then throw <| IO.userError s!"failed to copy save_olean_project fixture\n{out.stderr}" diff --git a/tests/lean/BeamTest/Broker/McpProtocolTest.lean b/tests/lean/BeamTest/Broker/McpProtocolTest.lean index aafde6de..e3f0c1f6 100644 --- a/tests/lean/BeamTest/Broker/McpProtocolTest.lean +++ b/tests/lean/BeamTest/Broker/McpProtocolTest.lean @@ -757,6 +757,51 @@ private def expectToolErrorCode (label expectedCode : String) (resp : Json) : IO requireJsonString s!"{label} structured error" "code" expectedCode structured pure structured +private def requireRuntimeActiveResult + (label : String) + (expected : Bool) + (response : Json) : IO Unit := do + let result ← requireObjVal s!"{label} response" "result" response + requireJsonBool s!"{label} result" "isError" false result + let structured ← requireObjVal s!"{label} result" "structuredContent" result + requireJsonBool s!"{label} structured" "runtime_active" expected structured + +private def requireLegacyRuntimeActive + (state : Beam.Mcp.Server.ServerState) + (opts : Beam.Mcp.Server.Options) + (label : String) + (id : Nat) + (expected : Bool) + (notifications : Beam.Mcp.Server.NotificationSink := {}) : IO Unit := do + let response ← handleRpcRequestWithNotifications state opts notifications label id "tools/call" <| + some <| toolCallParams "beam_version" + requireRuntimeActiveResult label expected response + +private def requireModernRuntimeActive + (state : Beam.Mcp.Server.ServerState) + (opts : Beam.Mcp.Server.Options) + (label : String) + (id : Nat) + (expected : Bool) : IO Unit := do + let response ← handleRpcRequest state opts label id "tools/call" <| some <| modernParams [ + ("name", toJson "beam_version"), + ("arguments", Json.mkObj []) + ] + requireRuntimeActiveResult label expected response + +private def legacyStatsWorkspaces + (state : Beam.Mcp.Server.ServerState) + (opts : Beam.Mcp.Server.Options) + (notifications : Beam.Mcp.Server.NotificationSink) + (label : String) + (id : Nat) : IO Json := do + let response ← handleRpcRequestWithNotifications state opts notifications label id "tools/call" <| + some <| toolCallParams "beam_stats" + let result ← requireObjVal s!"{label} response" "result" response + requireJsonBool s!"{label} result" "isError" false result + let structured ← requireObjVal s!"{label} result" "structuredContent" result + requireObjVal s!"{label} structured" "workspaces" structured + private def requireModernResultEnvelope (label : String) (result : Json) : IO Unit := do requireJsonString label "resultType" "complete" result let resultMeta ← requireObjVal label "_meta" result @@ -856,11 +901,8 @@ private def checkModernProtocol : IO Unit := do requireModernResultEnvelope "modern confidential beam_feedback_report result" feedbackResult requireConfidentialFeedbackResult "modern confidential beam_feedback_report" confidentialSecret feedbackResult - let stateAfterFeedback ← state.applicationState - require "modern beam_feedback_report should not create a broker runtime" - stateAfterFeedback.runtime?.isNone - require "modern beam_feedback_report should not create a workspace cache" - stateAfterFeedback.workspaces.toList.isEmpty + requireModernRuntimeActive state opts + "modern beam_feedback_report should not create a broker runtime" 1022 false let preservedMetaResult := Beam.Mcp.modernResult <| Json.mkObj [ ("_meta", Json.mkObj [("example.test/value", toJson "preserved")]) @@ -1166,11 +1208,8 @@ private def checkServerBasics : IO Unit := do requireConfidentialFeedbackResult "beam feedback confidential" confidentialSecret feedbackConfidentialResult - let stateAfterFeedback ← state.applicationState - require "beam_feedback_report should not create a broker runtime" - stateAfterFeedback.runtime?.isNone - require "beam_feedback_report should not create a workspace cache" - stateAfterFeedback.workspaces.toList.isEmpty + requireLegacyRuntimeActive state opts + "beam_feedback_report should not create a broker runtime" 26 false let uncachedDropResp ← handleRpcRequest state opts "drop uncached workspace" 24 "tools/call" <| some <| toolCallParams "lean_drop_workspace" <| withWorkspace root (Json.mkObj []) @@ -1188,11 +1227,8 @@ private def checkServerBasics : IO Unit := do uncachedDropStructured requireJsonString "drop uncached workspace structured result" "reason" "notFound" uncachedDropStructured - let stateAfterUncachedDrop ← state.applicationState - require "dropping an uncached workspace should not create a broker runtime" - stateAfterUncachedDrop.runtime?.isNone - require "dropping an uncached workspace should not create a workspace cache" - stateAfterUncachedDrop.workspaces.toList.isEmpty + requireLegacyRuntimeActive state opts + "dropping an uncached workspace should not create a broker runtime" 27 false let rawToolResp ← handleRpcRequest state opts "raw tool rejection" 3 "tools/call" <| some <| toolCallParams Beam.LSP.RunAt.method @@ -1406,12 +1442,6 @@ private def callLeanSync handleRpcRequestWithNotifications state opts notifications s!"lean_sync {path}" id "tools/call" <| some <| toolCallParams "lean_sync" arguments -private def shutdownMcpRuntime (state : Beam.Mcp.Server.ServerState) : IO Unit := do - match (← state.applicationState).runtime? with - | none => pure () - | some runtime => - discard <| runtime.dispatchRequest { op := .shutdown } - private def checkIdempotentLifecycleTools : IO Unit := do let root ← mkTempProjectRoot "lean-beam-mcp-idempotent-lifecycle" let state ← Beam.Mcp.Server.ServerState.create @@ -1426,8 +1456,15 @@ private def checkIdempotentLifecycleTools : IO Unit := do let syncResp ← callLeanSync state opts notifications root 2 "SaveSmoke/B.lean" let syncResult ← requireObjVal "lifecycle lean_sync response" "result" syncResp requireJsonBool "lifecycle lean_sync result" "isError" false syncResult - - for (id, label) in #[(3, "first"), (4, "repeated")] do + let canonicalRoot ← Beam.resolveExistingPath root + let workspaceId := (Beam.Workspace.Descriptor.ofRoot canonicalRoot).cacheKey + let workspaces ← legacyStatsWorkspaces state opts notifications + "lifecycle stats after sync" 3 + let workspaceStats ← requireObjVal + "lifecycle stats after sync workspaces" workspaceId workspaces + requireJsonString "lifecycle stats workspace" "root" canonicalRoot.toString workspaceStats + + for (id, label) in #[(4, "first"), (5, "repeated")] do let closeResp ← handleRpcRequestWithNotifications state opts notifications s!"{label} lean close" id "tools/call" <| some <| toolCallParams "lean_close" <| withWorkspace root <| Json.mkObj [ @@ -1440,7 +1477,7 @@ private def checkIdempotentLifecycleTools : IO Unit := do requireJsonBool s!"{label} lean_close structured result" "closed" true closeStructured let firstDropResp ← handleRpcRequestWithNotifications state opts notifications - "first lean drop workspace" 5 "tools/call" <| + "first lean drop workspace" 6 "tools/call" <| some <| toolCallParams "lean_drop_workspace" <| withWorkspace root (Json.mkObj []) let firstDropResult ← requireObjVal "first lean_drop_workspace response" "result" firstDropResp requireJsonBool "first lean_drop_workspace result" "isError" false firstDropResult @@ -1449,9 +1486,12 @@ private def checkIdempotentLifecycleTools : IO Unit := do requireJsonBool "first lean_drop_workspace structured result" "dropped" true firstDropStructured requireJsonBool "first lean_drop_workspace structured result" "invalidated_handles" true firstDropStructured + let workspaces ← legacyStatsWorkspaces state opts notifications + "lifecycle stats after drop" 7 + requireFieldAbsent "lifecycle stats after drop workspaces" workspaceId workspaces let repeatedDropResp ← handleRpcRequestWithNotifications state opts notifications - "repeated lean drop workspace" 6 "tools/call" <| + "repeated lean drop workspace" 8 "tools/call" <| some <| toolCallParams "lean_drop_workspace" <| withWorkspace root (Json.mkObj []) let repeatedDropResult ← requireObjVal "repeated lean_drop_workspace response" "result" repeatedDropResp @@ -1464,8 +1504,13 @@ private def checkIdempotentLifecycleTools : IO Unit := do repeatedDropStructured requireJsonString "repeated lean_drop_workspace structured result" "reason" "notFound" repeatedDropStructured + + state.closeRuntime + requireLegacyRuntimeActive state opts + "MCP runtime close should clear ServerState ownership" 9 false notifications + state.closeRuntime finally - shutdownMcpRuntime state + state.closeRuntime try if ← root.pathExists then IO.FS.removeDirAll root @@ -1606,7 +1651,7 @@ private def checkDiagnosticLogForwarding : IO Unit := do requireJsonBool "error log data" "completion_blocking" false errorData requireFieldAbsent "error log data" "save_blocking" errorData finally - shutdownMcpRuntime state + state.closeRuntime try if ← root.pathExists then IO.FS.removeDirAll root diff --git a/tests/lean/BeamTest/Broker/PendingTest.lean b/tests/lean/BeamTest/Broker/PendingTest.lean index e29defee..a098855e 100644 --- a/tests/lean/BeamTest/Broker/PendingTest.lean +++ b/tests/lean/BeamTest/Broker/PendingTest.lean @@ -29,14 +29,13 @@ private def mkPending (tracked? : Option (DocumentUri × Nat) := none) (emitProgress? : Option (SyncFileProgress → IO Unit) := none) (diagnosticScope : DiagnosticScope := .errors) - (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : - IO (PendingRequest × IO.Promise (Except ResponseFailure PendingResult)) := do + (emitDiagnostic? : Option (StreamDiagnostic → IO Unit) := none) : IO PendingRequest := do let promise ← IO.Promise.new let progressRef ← IO.mkRef progress? let diagnosticsRef ← IO.mkRef #[] let diagnosticsSeenRef ← IO.mkRef false let seenDiagnosticKeysRef ← IO.mkRef ({} : Std.TreeSet String compare) - pure ({ + pure { cancelRef? promise tracked? @@ -47,29 +46,38 @@ private def mkPending emitProgress? diagnosticScope emitDiagnostic? - }, promise) + } private def expectRegistered (label : String) - (result : Except BrokerFailure (Option ActiveRequest)) : IO (Option ActiveRequest) := do + (result : Except BrokerFailure ActiveRequest) : IO ActiveRequest := do match result with - | .ok active? => pure active? + | .ok active => pure active | .error failure => throw <| IO.userError s!"{label}: {failure.message}" private def checkActiveRegistry : IO Unit := do let registry ← ActiveRequestRegistry.create - let noneResult ← ActiveRequestRegistry.register registry none - let noneActive : Option ActiveRequest ← - expectRegistered "register without clientRequestId" noneResult - require "register without clientRequestId returns none" (Option.isNone noneActive) - - let firstResult ← ActiveRequestRegistry.register registry (some "req-1") - let first? : Option ActiveRequest ← - expectRegistered "register active request" firstResult - let some first := first? - | throw <| IO.userError "register active request returned none" - match ← ActiveRequestRegistry.register registry (some "req-1") with + let noneResult ← ActiveRequestRegistry.register registry none none + let anonymous ← expectRegistered "register without clientRequestId" noneResult + require "anonymous admission participates in active request count" + ((← ActiveRequestRegistry.count registry) == 1) + require "anonymous admission can be cancelled by its exact handle" + (Option.isSome (← ActiveRequestRegistry.markCancelledActive registry anonymous)) + match ← ensureRequestNotCancelled (some anonymous.cancelRef) with + | .ok _ => throw <| IO.userError "anonymous admission did not observe cancellation" + | .error failure => + discard <| requireFailureCode + "anonymous admission reports broker cancellation" + "requestCancelled" + failure + ActiveRequestRegistry.unregister registry (some anonymous) + require "unregistered anonymous admission leaves no active request" + ((← ActiveRequestRegistry.count registry) == 0) + + let firstResult ← ActiveRequestRegistry.register registry none (some "req-1") + let first ← expectRegistered "register active request" firstResult + match ← ActiveRequestRegistry.register registry none (some "req-1") with | .ok _ => throw <| IO.userError "duplicate clientRequestId registered successfully" | .error failure => @@ -77,7 +85,7 @@ private def checkActiveRegistry : IO Unit := do require "duplicate active request error names id" (failure.message.contains "req-1") require "mark active request cancelled" - (Option.isSome (← ActiveRequestRegistry.markCancelled registry "req-1")) + (Option.isSome (← ActiveRequestRegistry.markCancelled registry none "req-1")) match ← ensureRequestNotCancelled (some (ActiveRequest.cancelRef first)) with | .ok _ => throw <| IO.userError "ensureRequestNotCancelled reports broker cancellation: expected error" @@ -87,16 +95,13 @@ private def checkActiveRegistry : IO Unit := do "requestCancelled" failure - ActiveRequestRegistry.unregister registry first? + ActiveRequestRegistry.unregister registry (some first) require "unregistered active request is no longer cancellable" - (Option.isNone (← ActiveRequestRegistry.markCancelled registry "req-1")) - - let replacementResult ← ActiveRequestRegistry.register registry (some "req-1") - let replacement? : Option ActiveRequest ← - expectRegistered "register replacement active request" replacementResult - let some replacement := replacement? - | throw <| IO.userError "register replacement active request returned none" - ActiveRequestRegistry.unregister registry first? + (Option.isNone (← ActiveRequestRegistry.markCancelled registry none "req-1")) + + let replacementResult ← ActiveRequestRegistry.register registry none (some "req-1") + let replacement ← expectRegistered "register replacement active request" replacementResult + ActiveRequestRegistry.unregister registry (some first) require "stale active handle cannot cancel replacement" (Option.isNone (← ActiveRequestRegistry.markCancelledActive registry first)) match ← ensureRequestNotCancelled (some replacement.cancelRef) with @@ -105,7 +110,7 @@ private def checkActiveRegistry : IO Unit := do throw <| IO.userError s!"stale active handle cancelled replacement: {(toJson failure.toResponse).compress}" require "stale unregister preserves replacement active request" - (Option.isSome (← ActiveRequestRegistry.markCancelled registry "req-1")) + (Option.isSome (← ActiveRequestRegistry.markCancelled registry none "req-1")) match ← ensureRequestNotCancelled (some replacement.cancelRef) with | .ok _ => throw <| IO.userError "replacement active request did not observe cancellation" @@ -114,19 +119,67 @@ private def checkActiveRegistry : IO Unit := do "replacement active request reports broker cancellation" "requestCancelled" failure - ActiveRequestRegistry.unregister registry replacement? + ActiveRequestRegistry.unregister registry (some replacement) + + let workspaceA ← expectRegistered "register workspace A request" <| + ← ActiveRequestRegistry.register registry (some "workspace-a") (some "shared-id") + let workspaceB ← expectRegistered "register workspace B request" <| + ← ActiveRequestRegistry.register registry (some "workspace-b") (some "shared-id") + require "workspace-scoped cancellation finds the selected request" + (Option.isSome (← ActiveRequestRegistry.markCancelled registry + (some "workspace-a") "shared-id")) + match ← ensureRequestNotCancelled (some workspaceA.cancelRef) with + | .ok _ => throw <| IO.userError "workspace A request did not observe cancellation" + | .error _ => pure () + match ← ensureRequestNotCancelled (some workspaceB.cancelRef) with + | .ok _ => pure () + | .error _ => throw <| IO.userError "workspace A cancellation leaked into workspace B" + require "unscoped cancellation does not alias a workspace-scoped request" + (Option.isNone (← ActiveRequestRegistry.markCancelled registry none "shared-id")) + ActiveRequestRegistry.unregister registry (some workspaceA) + ActiveRequestRegistry.unregister registry (some workspaceB) + +private def checkActiveRegistryCloseDrain : IO Unit := do + let registry ← ActiveRequestRegistry.create + let named ← expectRegistered "register named request before close" <| + ← ActiveRequestRegistry.register registry none (some "closing-request") + let anonymous ← expectRegistered "register anonymous request before close" <| + ← ActiveRequestRegistry.register registry none none + ActiveRequestRegistry.closeAdmission registry + for active in #[named, anonymous] do + match ← ensureRequestNotCancelled (some active.cancelRef) with + | .ok _ => throw <| IO.userError "admission close did not cancel an active request" + | .error failure => + discard <| requireFailureCode + "admission close cancellation" + "requestCancelled" + failure + match ← ActiveRequestRegistry.register registry none (some "after-close") with + | .ok _ => throw <| IO.userError "closed admission accepted a new request" + | .error failure => + require "closed admission rejection is typed" (failure.code == .requestCancelled) + let drainTask ← IO.asTask (prio := Task.Priority.dedicated) <| + ActiveRequestRegistry.awaitDrained registry + ActiveRequestRegistry.unregister registry (some named) + IO.sleep 10 + require "drain should wait for every admitted request" (!(← IO.hasFinished drainTask)) + ActiveRequestRegistry.unregister registry (some anonymous) + match ← IO.wait drainTask with + | .ok () => pure () + | .error err => throw err + ActiveRequestRegistry.closeAdmission registry + require "repeated admission close should preserve the drained state" + ((← ActiveRequestRegistry.count registry) == 0) private def checkPendingCancellationIdentity : IO Unit := do let registry ← ActiveRequestRegistry.create - let firstResult ← ActiveRequestRegistry.register registry (some "reused-id") - let some first ← expectRegistered "register first cancellation identity" firstResult - | throw <| IO.userError "register first cancellation identity returned none" + let firstResult ← ActiveRequestRegistry.register registry none (some "reused-id") + let first ← expectRegistered "register first cancellation identity" firstResult ActiveRequestRegistry.unregister registry (some first) - let replacementResult ← ActiveRequestRegistry.register registry (some "reused-id") - let some replacement ← expectRegistered "register replacement cancellation identity" replacementResult - | throw <| IO.userError "register replacement cancellation identity returned none" - let (firstPending, _) ← mkPending (cancelRef? := some first.cancelRef) - let (replacementPending, _) ← mkPending (cancelRef? := some replacement.cancelRef) + let replacementResult ← ActiveRequestRegistry.register registry none (some "reused-id") + let replacement ← expectRegistered "register replacement cancellation identity" replacementResult + let firstPending ← mkPending (cancelRef? := some first.cancelRef) + let replacementPending ← mkPending (cancelRef? := some replacement.cancelRef) require "first admission matches its pending request" (← PendingRequestStore.matchesCancellation firstPending first.cancelRef) require "first admission does not match replacement pending request" @@ -139,7 +192,7 @@ private def checkPendingCancellationIdentity : IO Unit := do private def checkPendingStoreResolve : IO Unit := do let store ← PendingRequestStore.create - let (pending, promise) ← mkPending + let pending ← mkPending (progress? := some { updates := 3, done := false }) let id : RequestID := 7 PendingRequestStore.insert store id pending @@ -149,7 +202,7 @@ private def checkPendingStoreResolve : IO Unit := do | throw <| IO.userError "pending store remove missed inserted request" PendingRequest.resolveResponse pending (Json.mkObj [("value", toJson true)]) let result ← - match ← PendingRequest.awaitOutcome promise with + match ← pending.awaitOutcome with | .ok result => pure result | .error failure => throw <| IO.userError @@ -166,35 +219,81 @@ private def checkPendingStoreFailAll : IO Unit := do let store ← PendingRequestStore.create let firstProgress : SyncFileProgress := { updates := 5, done := false } let secondProgress : SyncFileProgress := { updates := 8, done := true } - let (firstPending, firstPromise) ← mkPending (progress? := some firstProgress) - let (secondPending, secondPromise) ← mkPending (progress? := some secondProgress) + let cancelRef ← IO.mkRef true + let firstPending ← mkPending + (progress? := some firstProgress) (cancelRef? := some cancelRef) + let secondPending ← mkPending (progress? := some secondProgress) PendingRequestStore.insert store 11 firstPending PendingRequestStore.insert store 12 secondPending - PendingRequestStore.failAll store (responseFailureFor .workerExited "worker exited") - for (label, promise, expectedProgress) in #[ - ("first", firstPromise, firstProgress), - ("second", secondPromise, secondProgress) + PendingRequestStore.failAll store + (responseFailureFor .workerExited "worker exited") + for (label, pending, expectedCode, expectedProgress) in #[ + ("cancelled", firstPending, "requestCancelled", firstProgress), + ("worker-exited", secondPending, "workerExited", secondProgress) ] do - match ← PendingRequest.awaitOutcome promise with + match ← pending.awaitOutcome with | .ok _ => - throw <| IO.userError s!"failAll resolves {label} pending request as an error: expected error" + throw <| IO.userError + s!"failAll resolves {label} pending request as an error: expected error" | .error failure => discard <| requireFailureCode - s!"failAll resolves {label} pending request as an error" "workerExited" failure + s!"failAll resolves {label} pending request as an error" + expectedCode failure require s!"failAll preserves {label} pending request progress" (failure.fileProgress? == some expectedProgress) require "failAll clears pending store" ((← PendingRequestStore.snapshot store).isEmpty) +private def checkPendingOutcomeCancellationPrecedence : IO Unit := do + let progress : SyncFileProgress := { updates := 13, done := true } + let backendFailure := + (responseFailureFor .contentModified "backend worker terminated") + |>.withOptionalFileProgress (some progress) + let cancelRef ← IO.mkRef true + let cancelledPending ← mkPending (cancelRef? := some cancelRef) + cancelledPending.promise.resolve (.error backendFailure) + match ← cancelledPending.awaitOutcome with + | .ok _ => + throw <| IO.userError "cancelled pending backend failure resolved as a success" + | .error failure => + discard <| requireFailureCode + "marked cancellation takes precedence over a backend failure" + "requestCancelled" + failure + require "cancellation precedence preserves backend failure progress" + (failure.fileProgress? == some progress) + + cancelRef.set false + let backendFailurePending ← mkPending (cancelRef? := some cancelRef) + backendFailurePending.promise.resolve (.error backendFailure) + match ← backendFailurePending.awaitOutcome with + | .ok _ => + throw <| IO.userError "uncancelled pending backend failure resolved as a success" + | .error failure => + discard <| requireFailureCode + "backend failure remains authoritative without cancellation" + "contentModified" + failure + + cancelRef.set true + let successPending ← mkPending (cancelRef? := some cancelRef) + successPending.promise.resolve (.ok { result := Json.mkObj [("completed", toJson true)] }) + match ← successPending.awaitOutcome with + | .error failure => + throw <| IO.userError + s!"completed backend success lost to later cancellation: {(toJson failure.toResponse).compress}" + | .ok result => + requireJsonBool "completed backend success remains authoritative" "completed" true result.result + private def checkPendingResolveError : IO Unit := do let expectedProgress : SyncFileProgress := { updates := 4, done := false } - let (pending, promise) ← mkPending (progress? := some expectedProgress) + let pending ← mkPending (progress? := some expectedProgress) let data := Json.mkObj [ ("expectedVersion", toJson (4 : Nat)), ("acceptedVersion", toJson (5 : Nat)) ] PendingRequest.resolveError pending .contentModified "document changed" (some data) - match ← PendingRequest.awaitOutcome promise with + match ← pending.awaitOutcome with | .ok _ => throw <| IO.userError "pending typed error resolved as a success" | .error failure => @@ -245,7 +344,7 @@ private def mkPublishDiagnostics (diagnostics : Array Diagnostic) : PublishDiagn private def observeFileProgress (progress : SyncFileProgress) (ranges : Array Range) : IO SyncFileProgress := do - let (pending, _) ← mkPending + let pending ← mkPending (progress? := some progress) (tracked? := some ("file:///workspace/Foo.lean", 1)) PendingRequest.observeProgress pending (mkFileProgress ranges) @@ -313,7 +412,7 @@ private def checkDiagnosticLineCanExceedProgressRange : IO Unit := do }) let farDiagnostic := mkDiagnostic (mkRange 20 2 20 8) "diagnostic beyond progress range" - let (pending, _) ← mkPending + let pending ← mkPending (progress? := some finished) (tracked? := some ("file:///workspace/Foo.lean", 1)) PendingRequest.observeDiagnostics @@ -332,7 +431,7 @@ private def observeStreamedDiagnostics (diagnosticScope : DiagnosticScope) (diagnostics : Array Diagnostic) : IO (Array StreamDiagnostic) := do let streamedRef ← IO.mkRef #[] - let (pending, _) ← mkPending + let pending ← mkPending (tracked? := some ("file:///workspace/Foo.lean", 1)) (diagnosticScope := diagnosticScope) (emitDiagnostic? := some fun diagnostic => @@ -345,7 +444,7 @@ private def observeStreamedDiagnostics private def checkDiagnosticEmitterFailureIsolation : IO Unit := do let diagnostic := mkDiagnostic (mkRange 1 0 1 4) "stream consumer disconnected" - let (pending, _) ← mkPending + let pending ← mkPending (tracked? := some ("file:///workspace/Foo.lean", 1)) (diagnosticScope := .all) (emitDiagnostic? := some fun _ => @@ -360,7 +459,7 @@ private def checkDiagnosticEmitterFailureIsolation : IO Unit := do ((← pending.diagnosticsRef.get).map (·.message) == #[diagnostic.message]) private def checkProgressEmitterFailureIsolation : IO Unit := do - let (pending, _) ← mkPending + let pending ← mkPending (progress? := some {}) (tracked? := some ("file:///workspace/Foo.lean", 1)) (emitProgress? := some fun _ => @@ -405,9 +504,11 @@ private def checkSetupFileProgressStreamsByScope : IO Unit := do def main : IO Unit := do checkActiveRegistry + checkActiveRegistryCloseDrain checkPendingCancellationIdentity checkPendingStoreResolve checkPendingStoreFailAll + checkPendingOutcomeCancellationPrecedence checkPendingResolveError checkSyncFileProgressDisplay checkSyncFileProgressLines diff --git a/tests/lean/BeamTest/Broker/ProcessUtil.lean b/tests/lean/BeamTest/Broker/ProcessUtil.lean index 961a73ea..eeeacccd 100644 --- a/tests/lean/BeamTest/Broker/ProcessUtil.lean +++ b/tests/lean/BeamTest/Broker/ProcessUtil.lean @@ -148,7 +148,8 @@ def killLeanServerForEndpoint def spawnLeanBrokerWithPlugin (endpoint : Beam.Broker.Endpoint) (root leanPlugin : System.FilePath) - (leanCmd : String := "lean") : IO (IO.Process.Child nullBrokerStdio) := do + (leanCmd : String := "lean") + (identity? : Option Beam.Broker.DaemonIdentity := none) : IO (IO.Process.Child nullBrokerStdio) := do let port := match endpoint with | .tcp port => port @@ -161,15 +162,21 @@ def spawnLeanBrokerWithPlugin "--workspace-id", testWorkspaceId, "--lean-cmd", leanCmd, "--lean-plugin", leanPlugin.toString - ] + ] ++ match identity? with + | some identity => #[ + "--daemon-id", identity.daemonId, + "--config-hash", identity.configHash + ] + | none => #[] setsid := true } def spawnLeanBroker (endpoint : Beam.Broker.Endpoint) (root : System.FilePath) - (leanCmd : String := "lean") : IO (IO.Process.Child nullBrokerStdio) := do - spawnLeanBrokerWithPlugin endpoint root (← BeamTest.TestHarness.pluginPath) leanCmd + (leanCmd : String := "lean") + (identity? : Option Beam.Broker.DaemonIdentity := none) : IO (IO.Process.Child nullBrokerStdio) := do + spawnLeanBrokerWithPlugin endpoint root (← BeamTest.TestHarness.pluginPath) leanCmd identity? def spawnRocqBroker (endpoint : Beam.Broker.Endpoint) diff --git a/tests/lean/BeamTest/Broker/ProtocolTest.lean b/tests/lean/BeamTest/Broker/ProtocolTest.lean index b2672b8e..015f7310 100644 --- a/tests/lean/BeamTest/Broker/ProtocolTest.lean +++ b/tests/lean/BeamTest/Broker/ProtocolTest.lean @@ -648,7 +648,7 @@ private def checkRequestArgsBoundary : IO Unit := do codeActionResolveRocqUnsupported.codeActionResolveArgs private def checkWorkspaceRoutingFields : IO Unit := do - let processWideOps := #[Op.cancel, .listWorkspaces, .resetStats, .shutdown] + let processWideOps := #[Op.listWorkspaces, .resetStats, .shutdown] let optionallyScopedOps := #[Op.openDocs, .stats] for op in Op.all do @@ -661,6 +661,9 @@ private def checkWorkspaceRoutingFields : IO Unit := do .required require s!"{op.key} has the wrong workspace scope" (op.workspaceScope == expectedScope) + let expectedTracking := op != .cancel && op != .shutdown + require s!"{op.key} has the wrong active-request tracking policy" + (op.tracksActiveRequest == expectedTracking) let request : Request := { op } let decoded ← expectOk s!"minimal {op.key} request round trip" <| fromJson? (α := Request) (toJson request) @@ -776,6 +779,54 @@ private def checkWorkspaceRoutingFields : IO Unit := do require "unsupported workspace mode error should name accepted values" (err.contains "'set', 'verify', or 'reset'") +private def checkProjectRequestBoundary : IO Unit := do + let semanticJson := Json.mkObj [ + ("op", toJson Op.runAt), + ("backend", toJson Backend.lean), + ("clientRequestId", toJson "project-request"), + ("path", toJson "Demo.lean"), + ("version", toJson (1 : Nat)), + ("line", toJson (0 : Nat)), + ("character", toJson (0 : Nat)), + ("text", toJson "exact rfl") + ] + let projectRequest ← expectOk "semantic project request" <| + fromJson? (α := ProjectRequest) semanticJson + let attached := projectRequest.attach "workspace-a" "/workspace/a" "session-capability" + require "project request attachment injects the selected workspace" + (attached.workspaceId? == some "workspace-a") + require "project request attachment injects the owner-side root" + (attached.root? == some "/workspace/a") + require "project request attachment injects session authority" + (attached.daemonCapability? == some "session-capability") + match fromJson? (α := ProjectRequest) <| Json.mkObj [("op", toJson Op.stats)] with + | .ok _ => throw <| IO.userError "project request unexpectedly accepted a missing request id" + | .error err => + require "project request id rejection should explain the machine identity requirement" + (err.contains "non-empty clientRequestId") + let cancelRequest ← expectOk "semantic cancellation request" <| + fromJson? (α := ProjectRequest) <| Json.mkObj [ + ("op", toJson Op.cancel), + ("clientRequestId", toJson "cancel-command"), + ("cancelRequestId", toJson "project-request") + ] + let attachedCancel := cancelRequest.attach "workspace-a" "/workspace/a" "session-capability" + require "project cancellation is workspace-scoped" + (attachedCancel.workspaceId? == some "workspace-a") + require "project cancellation does not invent an unsupported root field" + attachedCancel.root?.isNone + for field in ["workspaceId", "root", "daemonCapability", "leanCmd"] do + match fromJson? (α := ProjectRequest) (semanticJson.setObjVal! field (toJson "forbidden")) with + | .ok _ => throw <| IO.userError s!"project request unexpectedly accepted session field '{field}'" + | .error _ => pure () + for op in [Op.initWorkspace, .listWorkspaces, .dropWorkspace, .resetStats, .shutdown] do + match fromJson? (α := ProjectRequest) <| Json.mkObj [ + ("op", toJson op), + ("clientRequestId", toJson "control-request") + ] with + | .ok _ => throw <| IO.userError s!"project request unexpectedly accepted control op '{op.key}'" + | .error _ => pure () + private def checkWorkspaceLifecycleProtocol : IO Unit := do let root := System.FilePath.mk "/workspace" let previous := System.FilePath.mk "/previous-workspace" @@ -789,12 +840,16 @@ private def checkWorkspaceLifecycleProtocol : IO Unit := do let runtime ← Beam.Broker.ServerRuntime.create ({ root } : Beam.Broker.BrokerConfig) "fixture" + require "broker workspace query should expose the constructor workspace" + ((← runtime.workspaceRoot? "fixture") == some root) + require "broker workspace query should reject an unknown workspace" + ((← runtime.workspaceRoot? "unknown") == none) for op in #[Op.ensure, .initWorkspace, .dropWorkspace] do - let (missingWorkspaceResp, _) ← runtime.dispatchRequest { op } + let missingWorkspaceResp ← runtime.dispatchRequest { op } require s!"{op.key} should reject omitted workspace identity" (missingWorkspaceResp.error?.any fun err => err.code == "invalidParams" && err.message.contains "workspaceId is required") - let (processStatsResp, _) ← runtime.dispatchRequest { op := .stats } + let processStatsResp ← runtime.dispatchRequest { op := .stats } let some processStats := processStatsResp.result? | throw <| IO.userError s!"process-wide stats failed: {(toJson processStatsResp).compress}" requireFieldAbsent "process-wide stats" "root" processStats @@ -858,6 +913,246 @@ private def checkWorkspaceLifecycleProtocol : IO Unit := do fromJson? (α := Beam.Workspace.DropResult) dropJson require "typed workspace drop preserves lifecycle state" (decodedDrop.workspaceId == "fixture" && decodedDrop.dropped && decodedDrop.invalidatedHandles) + let dropResult ← runtime.dropWorkspace "fixture" + match dropResult with + | .error failure => + throw <| IO.userError s!"broker workspace drop failed: {failure.error.message}" + | .ok dropped => + require "broker workspace drop should succeed" dropped.dropped + require "broker workspace drop should invalidate handles" dropped.invalidatedHandles + require "broker workspace query should observe a dropped workspace" + ((← runtime.workspaceRoot? "fixture") == none) + + match ← runtime.initWorkspaceWithConfig "fixture" ({ root } : Beam.Broker.BrokerConfig) with + | .error failure => + throw <| IO.userError s!"typed broker workspace initialization failed: {failure.error.message}" + | .ok initialized => + require "typed broker workspace initialization should return its workspace" + (initialized.workspaceId == "fixture" && initialized.root == root) + require "typed broker workspace initialization should report a new runtime" + (!initialized.runtimeReused && !initialized.invalidatedHandles) + +private inductive LifecycleTeardown where + | reset + | drop + +private def LifecycleTeardown.label : LifecycleTeardown → String + | .reset => "reset" + | .drop => "drop" + +private partial def waitForPath + (path : System.FilePath) + (tries : Nat := 200) : IO Bool := do + if ← path.pathExists then + pure true + else if tries == 0 then + pure false + else + IO.sleep 10 + waitForPath path (tries - 1) + +private partial def waitForTaskBefore + (task : Task α) + (blockedBy : Task β) + (tries : Nat := 300) : IO (Option α) := do + if ← IO.hasFinished blockedBy then + pure none + else if ← IO.hasFinished task then + pure <| some (← IO.wait task) + else if tries == 0 then + pure none + else + IO.sleep 10 + waitForTaskBefore task blockedBy (tries - 1) + +private def stubbornSession + (workspaceId : WorkspaceId) + (root sentinel : System.FilePath) : IO Session := do + let proc ← IO.Process.spawn { + toStdioConfig := brokerStdio + cmd := "python3" + args := #[ + "-c", + "import pathlib, sys, time; sys.stdin.buffer.readline(); pathlib.Path(sys.argv[1]).write_text('shutdown'); time.sleep(30)", + sentinel.toString + ] + } + let pending ← Std.Mutex.new ({} : Std.TreeMap Lean.JsonRpc.RequestID PendingRequest) + pure { + workspaceId + backend := .lean + root + epoch := 1 + sessionToken := s!"stubborn-{workspaceId}" + proc + stdin := IO.FS.Stream.ofHandle proc.stdin + stdout := IO.FS.Stream.ofHandle proc.stdout + pending + } + +private def runLifecycleTeardown + (kind : LifecycleTeardown) + (runtime : ServerRuntime) + (workspaceId : WorkspaceId) + (replacement : BrokerConfig) : IO (Except ResponseFailure Bool) := do + match kind with + | .reset => + match ← runtime.initWorkspaceWithConfig workspaceId replacement (some .reset) with + | .ok result => pure <| .ok result.invalidatedHandles + | .error failure => pure <| .error failure + | .drop => + match ← runtime.dropWorkspace workspaceId with + | .ok result => pure <| .ok result.invalidatedHandles + | .error failure => pure <| .error failure + +private def checkLifecycleTeardownReleasesStateMutex + (kind : LifecycleTeardown) : IO Unit := do + let nonce ← IO.monoNanosNow + let targetId := "teardown-target" + let observerId := "teardown-observer" + let targetRoot := System.FilePath.mk s!"/tmp/beam-{kind.label}-target-{nonce}" + let replacementRoot := System.FilePath.mk s!"/tmp/beam-{kind.label}-replacement-{nonce}" + let observerRoot := System.FilePath.mk s!"/tmp/beam-{kind.label}-observer-{nonce}" + let sentinel := System.FilePath.mk s!"/tmp/beam-{kind.label}-shutdown-{nonce}" + let targetConfig : BrokerConfig := { root := targetRoot } + let replacementConfig : BrokerConfig := { root := replacementRoot } + let observerConfig : BrokerConfig := { root := observerRoot } + let runtime ← ServerRuntime.create targetConfig targetId + let session ← stubbornSession targetId targetRoot sentinel + runtime.state.atomically do + let state ← get + let targetWorkspace : WorkspaceState := { + config := targetConfig + lean := { nextEpoch := 2, session? := some session } + } + let observerWorkspace : WorkspaceState := { config := observerConfig } + let workspaces := state.workspaces.insert targetId targetWorkspace + let workspaces := workspaces.insert observerId observerWorkspace + set { state with workspaces } + let teardownTask ← IO.asTask (prio := Task.Priority.dedicated) <| + runLifecycleTeardown kind runtime targetId replacementConfig + try + unless ← waitForPath sentinel do + throw <| IO.userError s!"{kind.label}: backend did not enter shutdown" + require s!"{kind.label}: teardown fixture should still be waiting for the backend" + (!(← IO.hasFinished teardownTask)) + + let queryTask ← IO.asTask (prio := Task.Priority.dedicated) <| + runtime.workspaceRoot? observerId + let some queryOutcome ← waitForTaskBefore queryTask teardownTask + | throw <| IO.userError s!"{kind.label}: unrelated workspace query blocked on teardown" + let queryRoot ← + match queryOutcome with + | .ok queryRoot => pure queryRoot + | .error err => throw err + require s!"{kind.label}: unrelated workspace query should retain its root" + (queryRoot == some observerRoot) + require s!"{kind.label}: unrelated workspace query should finish before teardown" + (!(← IO.hasFinished teardownTask)) + + let teardownOutcome ← IO.wait teardownTask + let teardownResult ← + match teardownOutcome with + | .ok result => pure result + | .error err => throw err + match teardownResult with + | .error failure => + throw <| IO.userError s!"{kind.label}: lifecycle transition failed: {failure.error.message}" + | .ok invalidatedHandles => + require s!"{kind.label}: lifecycle transition should invalidate handles" invalidatedHandles + finally + try + session.proc.kill + catch _ => + pure () + if ← sentinel.pathExists then + IO.FS.removeFile sentinel + +private def checkLifecycleTeardownConcurrency : IO Unit := do + checkLifecycleTeardownReleasesStateMutex .reset + checkLifecycleTeardownReleasesStateMutex .drop + +private partial def waitForCancellation + (cancelRef : IO.Ref Bool) + (tries : Nat := 100) : IO Unit := do + if ← cancelRef.get then + pure () + else if tries == 0 then + throw <| IO.userError "timed out waiting for runtime close cancellation" + else + IO.sleep 10 + waitForCancellation cancelRef (tries - 1) + +private def checkSessionCloseAdmission : IO Unit := do + let root := System.FilePath.mk "/tmp/beam-session-close-admission" + let runtime ← Beam.Broker.ServerRuntime.create + ({ root } : Beam.Broker.BrokerConfig) "fixture" + let beforeClose ← runtime.dispatchRequest { op := .stats } + require "stats should be admitted before session close" beforeClose.ok + let active ← + match ← ActiveRequestRegistry.register runtime.activeRequests none (some "close-drain") with + | .ok active => pure active + | .error failure => throw <| IO.userError failure.message + let closeTask ← IO.asTask (prio := Task.Priority.dedicated) runtime.close + waitForCancellation active.cancelRef + let concurrentCloseTask ← IO.asTask (prio := Task.Priority.dedicated) runtime.close + IO.sleep 10 + require "runtime close should wait for admitted dispatch scopes" + (!(← IO.hasFinished closeTask)) + require "concurrent runtime close should share the same drain" + (!(← IO.hasFinished concurrentCloseTask)) + ActiveRequestRegistry.unregister runtime.activeRequests (some active) + match ← IO.wait closeTask with + | .ok () => pure () + | .error err => throw err + match ← IO.wait concurrentCloseTask with + | .ok () => pure () + | .error err => throw err + runtime.close + let afterClose ← runtime.dispatchRequest { op := .stats } + require "ordinary requests should be rejected after session close" + (afterClose.error?.any fun err => err.code == "requestCancelled") + let shutdown ← runtime.dispatchRequest { op := .shutdown } + require "shutdown remains idempotent after admission closes" shutdown.ok + require "closed admission should leave no active request" + ((← ActiveRequestRegistry.count runtime.activeRequests) == 0) + +private def checkWrapperDaemonAuthorization : IO Unit := do + let root := System.FilePath.mk "/tmp/beam-wrapper-daemon-authorization" + let capability := "generation-secret" + let runtime ← Beam.Broker.ServerRuntime.create + ({ root } : Beam.Broker.BrokerConfig) "fixture" + (.wrapper { daemonId := "generation-a", configHash := "config-a" } capability) + try + for (label, capability?) in [ + ("missing", none), + ("wrong", some "another-generation-secret") + ] do + let response ← runtime.dispatchRequest { + op := .stats + daemonCapability? := capability? + } + require s!"wrapper daemon should reject {label} capability" + (response.error?.any fun err => + err.code == "invalidParams" && err.message.contains "invalid Beam daemon capability") + + let stats ← runtime.dispatchRequest { + op := .stats + daemonCapability? := some capability + } + require "wrapper daemon should admit the exact generation capability" stats.ok + + for op in [Op.initWorkspace, .listWorkspaces, .dropWorkspace] do + let response ← runtime.dispatchRequest { + op + workspaceId? := some "fixture" + daemonCapability? := some capability + } + require s!"wrapper daemon should disable dynamic {op.key}" + (response.error?.any fun err => + err.code == "invalidParams" && err.message.contains "wrapper-owned daemon mode") + finally + runtime.close def main : IO Unit := do checkResponseJsonShape @@ -872,7 +1167,11 @@ def main : IO Unit := do checkStaleDirectDepHints checkRequestArgsBoundary checkWorkspaceRoutingFields + checkProjectRequestBoundary checkWorkspaceLifecycleProtocol + checkLifecycleTeardownConcurrency + checkSessionCloseAdmission + checkWrapperDaemonAuthorization end BeamTest.Broker.ProtocolTest diff --git a/tests/lean/BeamTest/Broker/RequestHandleTest.lean b/tests/lean/BeamTest/Broker/RequestHandleTest.lean index e251d19d..6195c37f 100644 --- a/tests/lean/BeamTest/Broker/RequestHandleTest.lean +++ b/tests/lean/BeamTest/Broker/RequestHandleTest.lean @@ -28,7 +28,7 @@ private def checkStaleHandleIsolation (server : Beam.Broker.ServerRuntime) (req : Beam.Broker.Request) : IO Unit := do let staleHandleRef ← IO.mkRef (none : Option Beam.Broker.RequestHandle) - let (completedResp, _) ← server.dispatchRequestWithHandle req (fun handle => do + let completedResp ← server.dispatchRequestWithHandle req (fun handle => do staleHandleRef.set (some handle) unless ← handle.cancel do throw <| IO.userError "completed broker request handle was not cancellable" @@ -39,7 +39,7 @@ private def checkStaleHandleIsolation let staleCancelledRef ← IO.mkRef false let replacementCancelledRef ← IO.mkRef false - let (replacementResp, _) ← server.dispatchRequestWithHandle req (fun replacement => do + let replacementResp ← server.dispatchRequestWithHandle req (fun replacement => do staleCancelledRef.set (← staleHandle.cancel) replacementCancelledRef.set (← replacement.cancel) pure true) @@ -68,7 +68,7 @@ def checkCancellationAndLifetime : IO Unit := do } let runOnce : IO Unit := do let handleRef ← IO.mkRef (none : Option Beam.Broker.RequestHandle) - let (resp, _) ← server.dispatchRequestWithHandle req (fun handle => do + let resp ← server.dispatchRequestWithHandle req (fun handle => do handleRef.set (some handle) unless ← handle.cancel do throw <| IO.userError "new broker request handle was not cancellable" @@ -84,8 +84,21 @@ def checkCancellationAndLifetime : IO Unit := do runOnce checkStaleHandleIsolation server req + let anonymousHandleRef ← IO.mkRef (none : Option Beam.Broker.RequestHandle) + let anonymousResp ← server.dispatchRequestWithHandle + { req with clientRequestId? := none } (fun handle => do + anonymousHandleRef.set (some handle) + unless ← handle.cancel do + throw <| IO.userError "anonymous broker request handle was not cancellable" + pure true) + checkCancelledResponse anonymousResp + let some anonymousHandle ← anonymousHandleRef.get + | throw <| IO.userError "anonymous broker request handle was not captured" + if ← anonymousHandle.cancel then + throw <| IO.userError "anonymous broker request handle remained active after dispatch" + let rejectedHandleRef ← IO.mkRef (none : Option Beam.Broker.RequestHandle) - let (rejectedResp, _) ← server.dispatchRequestWithHandle req (fun handle => do + let rejectedResp ← server.dispatchRequestWithHandle req (fun handle => do rejectedHandleRef.set (some handle) pure false) checkCancelledResponse rejectedResp @@ -96,7 +109,7 @@ def checkCancellationAndLifetime : IO Unit := do runOnce let failedHandleRef ← IO.mkRef (none : Option Beam.Broker.RequestHandle) - let (failedResp, _) ← server.dispatchRequestWithHandle req (fun handle => do + let failedResp ← server.dispatchRequestWithHandle req (fun handle => do failedHandleRef.set (some handle) throw <| IO.userError "before-dispatch test failure") checkErrorCode "failed before-dispatch callback" "internalError" failedResp @@ -108,7 +121,7 @@ def checkCancellationAndLifetime : IO Unit := do let callbackInvoked ← IO.mkRef false let invalidReq := { req with cancelRequestId? := some "unrelated-field" } - let (invalidResp, _) ← server.dispatchRequestWithHandle invalidReq (fun _ => do + let invalidResp ← server.dispatchRequestWithHandle invalidReq (fun _ => do callbackInvoked.set true pure true) checkErrorCode "invalid request before admission" "invalidParams" invalidResp diff --git a/tests/lean/BeamTest/Broker/RequestStreamContractTest.lean b/tests/lean/BeamTest/Broker/RequestStreamContractTest.lean index 2243f8d6..1a9a4d2d 100644 --- a/tests/lean/BeamTest/Broker/RequestStreamContractTest.lean +++ b/tests/lean/BeamTest/Broker/RequestStreamContractTest.lean @@ -5,6 +5,7 @@ Author: Emilio J. Gallego Arias -/ import Beam.Broker.Protocol +import Beam.Daemon.Protocol import BeamTest.Broker.RequestStreamUtil import BeamTest.Broker.TestUtil import BeamTest.Fixtures.TodoFixture @@ -55,14 +56,83 @@ private def syncVersion let result ← requireSyncFileResult s!"sync version for {path}" (← expectOk resp) pure result.version +private partial def waitForBrokerExit + (broker : IO.Process.Child nullBrokerStdio) + (tries : Nat := 200) : IO Unit := do + if (← broker.tryWait).isSome then + pure () + else if tries == 0 then + throw <| IO.userError "daemon remained alive after shutdown" + else + IO.sleep 25 + waitForBrokerExit broker (tries - 1) + +private def sendShutdownAndResetConnection (port : UInt16) : IO Unit := do + let payload := (toJson ({ op := .shutdown } : Beam.Broker.Request)).compress + let script := String.intercalate ";" [ + "import socket,struct,sys", + "s=socket.create_connection(('127.0.0.1',int(sys.argv[1])))", + "s.setsockopt(socket.SOL_SOCKET,socket.SO_LINGER,struct.pack('ii',1,0))", + "p=sys.argv[2].encode('utf-8')", + "s.sendall(str(len(p)).encode('ascii')+b'\\n'+p)", + "s.close()" + ] + let out ← IO.Process.output { + cmd := "python3" + args := #["-c", script, toString port.toNat, payload] + } + if out.exitCode != 0 then + throw <| IO.userError s!"failed to reset shutdown connection\n{out.stderr}" + +private def checkShutdownResponseBeforeExit (root : System.FilePath) : IO Unit := do + let port ← freshTcpPort + let endpoint : Beam.Broker.Endpoint := .tcp port + let broker ← spawnLeanBroker endpoint root + try + waitForBrokerReadyForRoot endpoint root + discard <| expectOk (← runClient endpoint { op := .shutdown }) + waitForBrokerExit broker + finally + try + broker.kill + catch _ => + pure () + try + discard <| broker.tryWait + catch _ => + pure () + def main : IO Unit := do let port ← freshTcpPort let endpoint : Beam.Broker.Endpoint := .tcp port let root ← mkTempProjectRoot "beam-daemon-request-stream" copySaveProjectFixture root - let broker ← spawnLeanBroker endpoint root + let identity : Beam.Broker.DaemonIdentity := { + daemonId := "request-stream-generation" + configHash := "request-stream-config" + } + let broker ← spawnLeanBroker endpoint root (identity? := some identity) try waitForBrokerReadyForRoot endpoint root + match ← Beam.Daemon.daemonGenerationStatus endpoint testWorkspaceId root identity "test-capability" with + | .exact => pure () + | status => + throw <| IO.userError + s!"daemon did not report its expected generation identity: {repr status}" + for mismatched in #[ + { identity with daemonId := identity.daemonId ++ "-other" }, + { identity with configHash := identity.configHash ++ "-other" } + ] do + match ← Beam.Daemon.daemonGenerationStatus endpoint testWorkspaceId root mismatched "test-capability" with + | .wrongGeneration _ => pure () + | status => + throw <| IO.userError s!"daemon generation mismatch was classified as {repr status}" + let otherRoot := root / "other-root" + IO.FS.createDirAll otherRoot + match ← Beam.Daemon.daemonGenerationStatus endpoint testWorkspaceId otherRoot identity "test-capability" with + | .wrongRoot _ => pure () + | status => + throw <| IO.userError s!"daemon root mismatch was classified as {repr status}" discard <| expectOk (← runClient endpoint { op := .ensure, root? := some root.toString }) let todoVersion ← syncVersion endpoint root BeamTest.Fixtures.TodoFixture.brokerPath @@ -244,13 +314,22 @@ def main : IO Unit := do expectErrorCode "stale trace save_olean" Beam.Broker.saveTraceStaleCode staleTraceSaveResp discard <| expectOk (← runClient endpoint { op := .stats }) - discard <| expectOk (← runClient endpoint { op := .shutdown }) + sendShutdownAndResetConnection port + waitForBrokerExit broker + match ← Beam.Daemon.daemonGenerationStatus endpoint testWorkspaceId root identity "test-capability" with + | .unavailable => pure () + | status => + throw <| IO.userError s!"stopped daemon was classified as {repr status}" + checkShutdownResponseBeforeExit root finally try broker.kill catch _ => pure () - discard <| broker.tryWait + try + discard <| broker.tryWait + catch _ => + pure () try IO.FS.removeDirAll root catch _ => diff --git a/tests/lean/BeamTest/Broker/StartupHandshakeTest.lean b/tests/lean/BeamTest/Broker/StartupHandshakeTest.lean index a3cfce45..f459d8f2 100644 --- a/tests/lean/BeamTest/Broker/StartupHandshakeTest.lean +++ b/tests/lean/BeamTest/Broker/StartupHandshakeTest.lean @@ -18,6 +18,7 @@ private def writeFakeServer (root : System.FilePath) : IO System.FilePath := do let body := String.intercalate "\n" [ "#!/usr/bin/env bash", "set -euo pipefail", + "printf '%s\\n' \"$$\" > \"$(dirname \"$0\")/fake-lean.pid\"", "frame() {", " local body=\"$1\"", " printf 'Content-Length: %s\\r\\n\\r\\n%s' \"${#body}\" \"$body\"", @@ -37,6 +38,19 @@ private def writeFakeServer (root : System.FilePath) : IO System.FilePath := do throw <| IO.userError s!"failed to chmod fake startup server\n{out.stderr}" pure script +private partial def waitForProcessGone (pid : Nat) (tries : Nat := 80) : IO Unit := do + let out ← IO.Process.output { + cmd := "/bin/kill" + args := #["-0", toString pid] + } + if out.exitCode != 0 then + pure () + else if tries == 0 then + throw <| IO.userError s!"failed provisional backend process {pid} remained alive" + else + IO.sleep 25 + waitForProcessGone pid (tries - 1) + def main : IO Unit := do let endpoint ← freshTcpEndpoint let root ← mkTempProjectRoot "beam-daemon-startup" @@ -54,6 +68,10 @@ def main : IO Unit := do throw <| IO.userError s!"expected internalError for startup failure, got {(toJson resp).compress}" unless err.message.contains "initialize failed" do throw <| IO.userError s!"expected startup failure to mention initialize failure, got {(toJson resp).compress}" + let pidText ← IO.FS.readFile (root / "fake-lean.pid") + let some pid := pidText.trimAscii.toString.toNat? + | throw <| IO.userError s!"invalid fake backend pid '{pidText}'" + waitForProcessGone pid finally try broker.kill diff --git a/tests/lean/BeamTest/Broker/StreamDedupTest.lean b/tests/lean/BeamTest/Broker/StreamDedupTest.lean index cf495276..1724da81 100644 --- a/tests/lean/BeamTest/Broker/StreamDedupTest.lean +++ b/tests/lean/BeamTest/Broker/StreamDedupTest.lean @@ -158,15 +158,13 @@ private def fakeServerWithLeanSession config lean := { nextEpoch := 1, session? := some session } } - pure { - state := ← Std.Mutex.new { + let server ← Beam.Broker.ServerRuntime.create config fixtureWorkspaceId + server.state.atomically do + set ({ bootstrapConfig := config workspaces := Std.TreeMap.empty.insert fixtureWorkspaceId workspace - } - endpoint := .tcp 0 - stop := ← IO.mkRef false - activeRequests := ← Beam.Broker.ActiveRequestRegistry.create - } + } : Beam.Broker.State) + pure server def checkRunAtStreamsSetupDiagnostics : IO Unit := do let rootBase := System.FilePath.mk s!"/tmp/beam-daemon-run-at-stream-{← IO.monoNanosNow}" @@ -186,7 +184,7 @@ def checkRunAtStreamsSetupDiagnostics : IO Unit := do let server ← fakeServerWithLeanSession root session let streamedRef ← IO.mkRef #[] try - let (resp, _) ← server.dispatchRequest { + let resp ← server.dispatchRequest { op := .runAt workspaceId? := some fixtureWorkspaceId root? := some root.toString diff --git a/tests/lib/beam-wrapper-common.sh b/tests/lib/beam-wrapper-common.sh index 8f2a25a5..d1e8f98f 100644 --- a/tests/lib/beam-wrapper-common.sh +++ b/tests/lib/beam-wrapper-common.sh @@ -10,6 +10,7 @@ client="" beam_wrapper_tmp_root="" declare -a beam_wrapper_managed_roots=() declare -a beam_wrapper_managed_pids=() +beam_wrapper_last_owner_pid="" # shellcheck source=tests/lib/wait.sh . tests/lib/wait.sh @@ -438,7 +439,7 @@ beam_wrapper_cleanup() { local pid root for pid in ${beam_wrapper_managed_pids[@]+"${beam_wrapper_managed_pids[@]}"}; do - kill "$pid" > /dev/null 2>&1 || true + kill -INT "$pid" > /dev/null 2>&1 || true wait "$pid" 2>/dev/null || true done @@ -456,6 +457,7 @@ beam_wrapper_init() { beam_wrapper_tmp_root="$(mktemp -d /tmp/beam-wrapper-suite-XXXXXX)" beam_wrapper_managed_roots=() beam_wrapper_managed_pids=() + beam_wrapper_last_owner_pid="" trap beam_wrapper_cleanup EXIT } @@ -467,6 +469,41 @@ beam_wrapper_register_pid() { beam_wrapper_managed_pids+=("$1") } +beam_wrapper_unregister_pid() { + local target="$1" + local pid + local -a kept_pids=() + for pid in ${beam_wrapper_managed_pids[@]+"${beam_wrapper_managed_pids[@]}"}; do + if [ "$pid" != "$target" ]; then + kept_pids+=("$pid") + fi + done + beam_wrapper_managed_pids=(${kept_pids[@]+"${kept_pids[@]}"}) +} + +beam_wrapper_start_owner() { + local root="$1" + local backend="${2:-lean}" + local out="$root/.beam/test-owner.out" + local err="$root/.beam/test-owner.err" + local registry="$root/.beam/beam-daemon.json" + + "$beam_script" --root "$root" ensure "$backend" --hold >"$out" 2>"$err" & + beam_wrapper_last_owner_pid="$!" + beam_wrapper_register_pid "$beam_wrapper_last_owner_pid" + if ! wait_for_file "$registry" "Beam session owner registry" 60 || + ! wait_for_file_text "$err" "owning Beam session" "Beam session owner readiness" 600 0.1; then + echo "expected explicit Beam session owner to become ready for $root" >&2 + if [ -f "$out" ]; then + cat "$out" >&2 + fi + if [ -f "$err" ]; then + cat "$err" >&2 + fi + exit 1 + fi +} + beam_wrapper_prepare_project_root() { local name="$1" local root="$beam_wrapper_tmp_root/$name" @@ -475,6 +512,7 @@ beam_wrapper_prepare_project_root() { rsync -a --exclude='.beam/' tests/save_olean_project/ "$root"/ rm -rf -- "$root/.beam" mkdir -p "$root/.beam" + chmod 700 "$root/.beam" beam_wrapper_register_root "$root" printf '%s\n' "$root" } diff --git a/tests/test-beam-fast.sh b/tests/test-beam-fast.sh index 9b7c9870..44e53b2c 100644 --- a/tests/test-beam-fast.sh +++ b/tests/test-beam-fast.sh @@ -248,7 +248,8 @@ env ${mcp_stdio_env[@]+"${mcp_stdio_env[@]}"} \ --restart-cycles 1 \ --timeout "$mcp_stdio_timeout" \ > /dev/null -python3 tests/test-mcp-http-bridge.py > /dev/null +mcp_http_timeout="${BEAM_MCP_HTTP_TIMEOUT:-60}" +python3 tests/test-mcp-http-bridge.py --timeout "$mcp_http_timeout" > /dev/null mcp_self_check_timeout="${BEAM_MCP_SELF_CHECK_TIMEOUT_MS:-120000}" (cd tests/save_olean_project && \ LEAN_BEAM_MCP_SELF_CHECK_TIMEOUT_MS="$mcp_self_check_timeout" \ @@ -352,15 +353,45 @@ wrapper_todo_update_out="$(mktemp /tmp/lean-beam-wrapper-todo-update-out-XXXXXX) wrapper_todo_update_err="$(mktemp /tmp/lean-beam-wrapper-todo-update-err-XXXXXX)" wrapper_todo_out="$(mktemp /tmp/lean-beam-wrapper-todo-out-XXXXXX)" wrapper_todo_err="$(mktemp /tmp/lean-beam-wrapper-todo-err-XXXXXX)" +wrapper_todo_owner_out="$(mktemp /tmp/lean-beam-wrapper-todo-owner-out-XXXXXX)" +wrapper_todo_owner_err="$(mktemp /tmp/lean-beam-wrapper-todo-owner-err-XXXXXX)" +wrapper_todo_owner_pid="" wrapper_todo_cleanup() { - BEAM_CONTROL_DIR="$wrapper_todo_control_dir" \ - scripts/lean-beam --root tests/save_olean_project shutdown > /dev/null 2>&1 || true + scripts/lean-beam --root tests/save_olean_project \ + --control-dir "$wrapper_todo_control_dir" shutdown > /dev/null 2>&1 || true + if [ -n "$wrapper_todo_owner_pid" ]; then + wait "$wrapper_todo_owner_pid" 2>/dev/null || true + fi rm -rf -- "$wrapper_todo_control_dir" - rm -f "$wrapper_todo_update_out" "$wrapper_todo_update_err" "$wrapper_todo_out" "$wrapper_todo_err" + rm -f "$wrapper_todo_update_out" "$wrapper_todo_update_err" "$wrapper_todo_out" "$wrapper_todo_err" \ + "$wrapper_todo_owner_out" "$wrapper_todo_owner_err" } -if ! BEAM_CONTROL_DIR="$wrapper_todo_control_dir" \ - scripts/lean-beam --root tests/save_olean_project \ +scripts/lean-beam --root tests/save_olean_project \ + --control-dir "$wrapper_todo_control_dir" ensure --hold \ + >"$wrapper_todo_owner_out" 2>"$wrapper_todo_owner_err" & +wrapper_todo_owner_pid="$!" +for _ in $(seq 1 600); do + if grep -Fq "owning Beam session" "$wrapper_todo_owner_err"; then + break + fi + if ! kill -0 "$wrapper_todo_owner_pid" 2>/dev/null; then + echo "expected lean-beam todo wrapper owner to remain alive" >&2 + cat "$wrapper_todo_owner_err" >&2 + wrapper_todo_cleanup + exit 1 + fi + sleep 0.1 +done +if ! grep -Fq "owning Beam session" "$wrapper_todo_owner_err"; then + echo "timed out waiting for lean-beam todo wrapper owner" >&2 + cat "$wrapper_todo_owner_err" >&2 + wrapper_todo_cleanup + exit 1 +fi + +if ! scripts/lean-beam --root tests/save_olean_project \ + --control-dir "$wrapper_todo_control_dir" \ update TodoSmoke.lean \ > "$wrapper_todo_update_out" 2>"$wrapper_todo_update_err"; then echo "expected lean-beam update wrapper smoke to succeed before todo" >&2 @@ -390,8 +421,8 @@ PY exit 1 fi -if ! BEAM_CONTROL_DIR="$wrapper_todo_control_dir" \ - scripts/lean-beam --root tests/save_olean_project \ +if ! scripts/lean-beam --root tests/save_olean_project \ + --control-dir "$wrapper_todo_control_dir" \ todo TodoSmoke.lean "$wrapper_todo_version" 13 0 14 0 --kind sorry --suggest none \ > "$wrapper_todo_out" 2>"$wrapper_todo_err"; then echo "expected lean-beam todo wrapper smoke to succeed" >&2 diff --git a/tests/test-beam-install.sh b/tests/test-beam-install.sh index 5f180af7..a686b77c 100644 --- a/tests/test-beam-install.sh +++ b/tests/test-beam-install.sh @@ -14,17 +14,33 @@ cd "$(dirname "$0")/.." . tests/lib/ci-steps.sh # shellcheck source=tests/lib/install-fixtures.sh . tests/lib/install-fixtures.sh +# shellcheck source=tests/lib/wait.sh +. tests/lib/wait.sh BEAM_TEST_SUITE="${BEAM_TEST_SUITE:-install}" BEAM_INSTALL_TEST_PRESEED_ELAN="${BEAM_INSTALL_TEST_PRESEED_ELAN:-auto}" tmp_root="$(mktemp -d /tmp/beam-install-XXXXXX)" +declare -a installed_owner_pids=() +declare -a installed_owner_roots=() host_elan_home="${ELAN_HOME:-}" if [ -z "$host_elan_home" ] && [ -d "$HOME/.elan" ]; then host_elan_home="$HOME/.elan" fi cleanup() { + local owner_pid owner_root + if [ -x "${installed_lean_beam:-}" ]; then + for owner_root in ${installed_owner_roots[@]+"${installed_owner_roots[@]}"}; do + "$installed_lean_beam" --root "$owner_root" shutdown > /dev/null 2>&1 || true + done + fi + for owner_pid in ${installed_owner_pids[@]+"${installed_owner_pids[@]}"}; do + if kill -0 "$owner_pid" 2>/dev/null; then + kill -INT "$owner_pid" 2>/dev/null || true + fi + wait "$owner_pid" 2>/dev/null || true + done expect_owned_tmp_dir "$tmp_root" rm -rf -- "$tmp_root" } @@ -291,7 +307,7 @@ assert_install_rejects_marker() { remove_tmp_file "$marker_err" } -rsync -a --exclude='.git' ./ "$source_checkout"/ +rsync -a --exclude='.git' --exclude='.beam/' ./ "$source_checkout"/ path_no_elan="$(path_without_elan)" if PATH="$path_no_elan" command -v elan >/dev/null 2>&1; then echo "failed to construct a PATH without elan for the negative install test" >&2 @@ -714,6 +730,24 @@ installed_helper="$HOME/.local/bin/lean-beam-search" installed_mcp="$HOME/.local/bin/lean-beam-mcp" installed_runtime_root="$BEAM_INSTALL_ROOT/current" +start_installed_owner() { + local root="$1" + local label="$2" + local owner_out="$tmp_root/$label-owner.out" + local owner_err="$tmp_root/$label-owner.err" + local owner_pid + "$installed_lean_beam" --root "$root" ensure --hold > "$owner_out" 2> "$owner_err" & + owner_pid="$!" + installed_owner_pids+=("$owner_pid") + installed_owner_roots+=("$root") + if ! wait_for_file_text "$owner_err" "owning Beam session" "$label session owner" 600 0.1; then + echo "expected installed wrapper owner to become ready for $root" >&2 + cat "$owner_out" >&2 + cat "$owner_err" >&2 + return 1 + fi +} + if [ ! -L "$installed_lean_beam" ]; then echo "expected installed lean-beam symlink at $installed_lean_beam" >&2 exit 1 @@ -943,8 +977,16 @@ run_custom_toolchain_install_test() ( assert_doctor_contains "custom toolchain" "$custom_doctor_out" 'project toolchain accepted: true' assert_doctor_contains "custom toolchain" "$custom_doctor_out" 'bundle source: installed' assert_doctor_contains "custom toolchain" "$custom_doctor_out" 'bundle toolchain fingerprint: ' - ELAN_HOME="$custom_elan_home" "$custom_installed_lean_beam" --root "$custom_project_root" ensure > /dev/null + custom_owner_err="$custom_project_root/custom-owner.err" + ELAN_HOME="$custom_elan_home" "$custom_installed_lean_beam" --root "$custom_project_root" ensure --hold \ + > /dev/null 2>"$custom_owner_err" & + custom_owner_pid="$!" + if ! wait_for_file_text "$custom_owner_err" "owning Beam session" "custom-toolchain session owner" 600 0.1; then + exit 1 + fi ELAN_HOME="$custom_elan_home" "$custom_installed_lean_beam" --root "$custom_project_root" shutdown > /dev/null + wait_for_exit "$custom_owner_pid" "custom-toolchain session owner" 120 0.1 + wait "$custom_owner_pid" ) run_step "install custom toolchain runtime" run_custom_toolchain_install_test @@ -1539,6 +1581,13 @@ if ! printf '%s\n' "$mcp_self_check_out" | grep -q 'workspace: explicit root des printf '%s\n' "$mcp_self_check_out" >&2 exit 1 fi +if [ -d "$project_root/.beam" ]; then + project_beam_mode="$(python3 -c 'import os, stat, sys; print(format(stat.S_IMODE(os.lstat(sys.argv[1]).st_mode), "o"))' "$project_root/.beam")" + if [ "$project_beam_mode" != "700" ]; then + echo "expected Beam-created project state to be private, got mode $project_beam_mode" >&2 + exit 1 + fi +fi unsupported_project_root="$tmp_root/external-project-unsupported" rsync -a --exclude='.beam/' tests/save_olean_project/ "$unsupported_project_root"/ @@ -1562,8 +1611,18 @@ if ! printf '%s\n' "$unsupported_doctor_out" | grep -q 'bundle toolchain fingerp fi unsupported_err="$(mktemp "$tmp_root/install-unsupported-toolchain-XXXXXX")" -if "$installed_lean_beam" --root "$unsupported_project_root" ensure >"$unsupported_err" 2>&1; then - echo "expected installed wrapper ensure lean to reject an unsupported toolchain" >&2 +"$installed_lean_beam" --root "$unsupported_project_root" ensure --hold >"$unsupported_err" 2>&1 & +unsupported_owner_pid="$!" +if ! wait_for_exit "$unsupported_owner_pid" "unsupported-toolchain session owner" 120 0.1; then + kill -INT "$unsupported_owner_pid" 2>/dev/null || true + wait "$unsupported_owner_pid" 2>/dev/null || true + echo "expected installed wrapper owner admission to reject an unsupported toolchain promptly" >&2 + cat "$unsupported_err" >&2 + remove_tmp_file "$unsupported_err" + exit 1 +fi +if wait "$unsupported_owner_pid"; then + echo "expected installed wrapper owner admission to reject an unsupported toolchain" >&2 cat "$unsupported_err" >&2 remove_tmp_file "$unsupported_err" exit 1 @@ -1589,6 +1648,8 @@ remove_tmp_file "$unsupported_err" printf 'def bVal : Nat := "broken"\n' > SaveSmoke/B.lean ) +start_installed_owner "$project_root" "installed-stale" + stale_sync_err="$(mktemp "$tmp_root/install-stale-sync-XXXXXX")" if "$installed_lean_beam" --root "$project_root" sync SaveSmoke/A.lean >"$stale_sync_err" 2>&1; then echo "expected installed wrapper sync to fail on a stale imported target" >&2 @@ -1620,6 +1681,8 @@ import SaveSmoke.B #check bVal EOF +start_installed_owner "$project_root_standalone" "installed-standalone" + standalone_sync="$("$installed_lean_beam" --root "$project_root_standalone" sync StandaloneSaveSmoke.lean)" if ! printf '%s\n' "$standalone_sync" | python3 -c 'import json,sys; payload=json.load(sys.stdin); sys.exit(0 if payload.get("error") is None else 1)'; then echo "expected installed wrapper sync to succeed on a standalone file the daemon can open" >&2 diff --git a/tests/test-beam-prune.sh b/tests/test-beam-prune.sh index 8c6faa38..1b262d47 100644 --- a/tests/test-beam-prune.sh +++ b/tests/test-beam-prune.sh @@ -56,6 +56,19 @@ write_runtime_manifest() { "$beam_cli" install-manifest "$payload" - fixture-toolchain >"$path" } +write_lock_owner() { + local lock_dir="$1" + local pid="$2" + printf '%s\n' "$pid" >"$lock_dir/pid" +} + +assert_lock_timeout() { + local path="$1" + assert_contains_literal "$path" 'timed out after ' + assert_contains_literal "$path" ' ms waiting for Beam lock' + assert_contains_literal "$path" 'timeout: 1000 ms' +} + mkdir -p \ "$current_runtime/bin" \ "$current_runtime/libexec" \ @@ -212,15 +225,17 @@ race_lock_held="$tmp_root/race-lock-held" race_lock_release="$tmp_root/race-lock-release" race_err="$tmp_root/race.err" ( + mkdir "$race_lock" touch "$race_lock_held" while [ ! -e "$race_lock_release" ]; do sleep 0.05 done + rm -f "$race_lock/pid" + rmdir "$race_lock" ) & lock_writer_pid="$!" wait_for_file "$race_lock_held" "prune install-lock holder" 10 -mkdir "$race_lock" -printf '%s\n' "$lock_writer_pid" >"$race_lock/pid" +write_lock_owner "$race_lock" "$lock_writer_pid" BEAM_HOME="$current_runtime" "$beam_cli" install-prune --apply > /dev/null 2>"$race_err" & race_pid="$!" sleep 0.3 @@ -244,13 +259,13 @@ rm -f "$install_root/current" ln -s "$current_runtime" "$install_root/current" mkdir "$install_root/.install-lock" -printf '%s\n' "$$" >"$install_root/.install-lock/pid" +write_lock_owner "$install_root/.install-lock" "$$" install_lock_err="$tmp_root/install-lock.err" if "$install_root/current/bin/lean-beam" prune --apply > /dev/null 2>"$install_lock_err"; then echo "expected prune to respect the active install lock" >&2 exit 1 fi -assert_contains_literal "$install_lock_err" 'timed out after 1000 ms waiting for Beam lock' +assert_lock_timeout "$install_lock_err" rm -f "$install_root/.install-lock/pid" rmdir "$install_root/.install-lock" assert_file "$old_runtime/manifest.json" @@ -286,8 +301,24 @@ PY resolved_partial_runtime="$(beam_test_realpath "$partial_runtime")" stale_bundle_lock="$bundle_root/.locks/200" -mkdir -p "$stale_bundle_lock" -printf '%s\n' "$$" >"$stale_bundle_lock/pid" +bundle_lock_held="$tmp_root/bundle-lock-held" +bundle_lock_release="$tmp_root/bundle-lock-release" +mkdir -p "$(dirname "$stale_bundle_lock")" +python3 - "$stale_bundle_lock" "$bundle_lock_held" "$bundle_lock_release" <<'PY' & +import fcntl +import pathlib +import sys +import time + +lock_path, held_path, release_path = map(pathlib.Path, sys.argv[1:]) +with lock_path.open("a", encoding="utf-8") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + held_path.touch() + while not release_path.exists(): + time.sleep(0.05) +PY +lock_writer_pid="$!" +wait_for_file "$bundle_lock_held" "prune bundle-lock holder" 10 bundle_lock_out="$tmp_root/bundle-lock.out" bundle_lock_err="$tmp_root/bundle-lock.err" if "$install_root/current/bin/lean-beam" prune --apply --bundles \ @@ -296,15 +327,16 @@ if "$install_root/current/bin/lean-beam" prune --apply --bundles \ exit 1 fi assert_contains_literal "$bundle_lock_out" "removed runtime: $resolved_partial_runtime" -assert_contains_literal "$bundle_lock_err" 'timed out after 1000 ms waiting for Beam lock' +assert_lock_timeout "$bundle_lock_err" assert_contains_literal "$bundle_lock_err" \ 'prune stopped before completing the displayed plan; any removals reported above were applied' # shellcheck disable=SC2016 assert_contains_literal "$bundle_lock_err" \ 'rerun `lean-beam prune --bundles` to preview the remaining paths' assert_not_exists "$partial_runtime" -rm -f "$stale_bundle_lock/pid" -rmdir "$stale_bundle_lock" +touch "$bundle_lock_release" +wait "$lock_writer_pid" +lock_writer_pid="" assert_file "$stale_bundle/metadata.json" apply_bundle_out="$("$install_root/current/bin/lean-beam" prune --apply --bundles)" diff --git a/tests/test-beam-save-olean.sh b/tests/test-beam-save-olean.sh index 3b26306b..627c028e 100755 --- a/tests/test-beam-save-olean.sh +++ b/tests/test-beam-save-olean.sh @@ -35,6 +35,23 @@ beam() { LAKE_ARTIFACT_CACHE=false "$beam_script" "$@" } +declare -a beam_owner_pids=() +beam_owner_last_pid="" + +beam_start_owner() { + local root="$1" + local owner_out="$root/.beam-test-owner.out" + local owner_err="$root/.beam-test-owner.err" + beam --root "$root" ensure --hold >"$owner_out" 2>"$owner_err" & + beam_owner_last_pid="$!" + beam_owner_pids+=("$beam_owner_last_pid") + if ! wait_for_file_text "$owner_err" "owning Beam session" "save-replay session owner" 600 0.1; then + cat "$owner_out" >&2 + cat "$owner_err" >&2 + exit 1 + fi +} + expect_owned_tmp_path() { case "$1" in /tmp/beam-save-olean-*|/tmp/tmp.*|/tmp/beam-validate-*/tmp/beam-save-olean-*|/tmp/beam-validate-*/tmp/tmp.*) @@ -351,6 +368,16 @@ save_race_broker_trace="${BEAM_SAVE_RACE_BROKER_TRACE:-1}" save_race_watchdog_ms="${BEAM_SAVE_RACE_WAIT_DIAGNOSTICS_WATCHDOG_MS:-10000}" cleanup() { + local root owner_pid + for root in "$tmp2" "$tmp3" "$tmp4" "$tmp5" "$tmp6" "$tmp7" "$tmp8"; do + if [ -d "$root" ]; then + beam --root "$root" shutdown > /dev/null 2>&1 || true + fi + done + for owner_pid in ${beam_owner_pids[@]+"${beam_owner_pids[@]}"}; do + kill -INT "$owner_pid" > /dev/null 2>&1 || true + wait "$owner_pid" 2>/dev/null || true + done remove_owned_tmp_tree "$tmp1" remove_owned_tmp_tree "$tmp2" remove_owned_tmp_tree "$tmp3" @@ -394,9 +421,9 @@ fi (cd "$tmp2" && lake_build > /dev/null) edit_b "$tmp2" +beam_start_owner "$tmp2" ( cd "$tmp2" - beam --root "$tmp2" shutdown > /dev/null 2>&1 || true save_json="$(beam --root "$tmp2" lean-close-save SaveSmoke/B.lean)" if [ "$(BEAM_JSON_PAYLOAD="$save_json" python3 - <<'PY' import json, os @@ -504,9 +531,9 @@ fi (cd "$tmp8" && lake_build SaveSmoke/ModuleB.lean > /dev/null) edit_module_b "$tmp8" +beam_start_owner "$tmp8" ( cd "$tmp8" - beam --root "$tmp8" shutdown > /dev/null 2>&1 || true module_save_json="$(beam --root "$tmp8" lean-close-save SaveSmoke/ModuleB.lean)" for artifact_key in olean oleanServer oleanPrivate ir; do artifact="$(BEAM_JSON_PAYLOAD="$module_save_json" BEAM_ARTIFACT_KEY="$artifact_key" python3 - <<'PY' @@ -539,9 +566,9 @@ fi (cd "$tmp7" && LAKE_ARTIFACT_CACHE=false "$lake_cmd" env lean CheckBatchOnly.lean > /dev/null) (cd "$tmp7" && LAKE_ARTIFACT_CACHE=false "$lake_cmd" env lean CheckStructured.lean > /dev/null) edit_structured_setup "$tmp7" +beam_start_owner "$tmp7" ( cd "$tmp7" - beam --root "$tmp7" shutdown > /dev/null 2>&1 || true structured_save="$(beam --root "$tmp7" lean-save StructuredSetup/UsesOption.lean)" if [ "$(BEAM_JSON_PAYLOAD="$structured_save" python3 - <<'PY' import json, os @@ -596,10 +623,13 @@ PY ) (cd "$tmp3" && lake_build > /dev/null) +remove_owned_tmp_file "$race_sentinel" +LEAN_BEAM_BROKER_TRACE="$save_race_broker_trace" \ + LEAN_BEAM_BROKER_WAIT_DIAGNOSTICS_WATCHDOG_MS="$save_race_watchdog_ms" \ + LEAN_BEAM_SAVE_RACE_SENTINEL="$race_sentinel" \ + beam_start_owner "$tmp3" ( cd "$tmp3" - beam --root "$tmp3" shutdown > /dev/null 2>&1 || true - remove_owned_tmp_file "$race_sentinel" LEAN_BEAM_BROKER_TRACE="$save_race_broker_trace" \ LEAN_BEAM_BROKER_WAIT_DIAGNOSTICS_WATCHDOG_MS="$save_race_watchdog_ms" \ LEAN_BEAM_SAVE_RACE_SENTINEL="$race_sentinel" \ @@ -637,16 +667,15 @@ fi (cd "$tmp4" && lake_build > /dev/null) edit_b_slow "$tmp4" +remove_owned_tmp_file "$cancel_sentinel" +LEAN_BEAM_BROKER_TRACE="$save_race_broker_trace" \ + LEAN_BEAM_BROKER_WAIT_DIAGNOSTICS_WATCHDOG_MS="$save_race_watchdog_ms" \ + LEAN_BEAM_SAVE_RACE_SENTINEL="$cancel_sentinel" \ + beam_start_owner "$tmp4" ( cd "$tmp4" - beam --root "$tmp4" shutdown > /dev/null 2>&1 || true close_out="$(mktemp /tmp/beam-close-save-cancel-out-XXXXXX)" close_err="$(mktemp /tmp/beam-close-save-cancel-err-XXXXXX)" - remove_owned_tmp_file "$cancel_sentinel" - LEAN_BEAM_BROKER_TRACE="$save_race_broker_trace" \ - LEAN_BEAM_BROKER_WAIT_DIAGNOSTICS_WATCHDOG_MS="$save_race_watchdog_ms" \ - LEAN_BEAM_SAVE_RACE_SENTINEL="$cancel_sentinel" \ - beam --root "$tmp4" ensure lean > /dev/null LEAN_BEAM_SAVE_RACE_SENTINEL="$cancel_sentinel" BEAM_REQUEST_ID=cancel-close-save \ beam --root "$tmp4" lean-close-save SaveSmoke/B.lean >"$close_out" 2>"$close_err" & close_pid=$! @@ -682,14 +711,14 @@ edit_b_slow "$tmp4" exit 1 fi beam --root "$tmp4" stats > /dev/null + beam --root "$tmp4" shutdown > /dev/null rm -f "$close_out" "$close_err" ) (cd "$tmp6" && lake_build SaveSmoke/A.lean > /dev/null) +beam_start_owner "$tmp6" ( cd "$tmp6" - beam --root "$tmp6" shutdown > /dev/null 2>&1 || true - beam --root "$tmp6" ensure lean > /dev/null beam --root "$tmp6" lean-sync SaveSmoke/A.lean > /dev/null edit_b "$tmp6" save_out="$(mktemp /tmp/beam-stale-trace-save-out-XXXXXX)" @@ -716,15 +745,15 @@ edit_b_slow "$tmp4" exit 1 fi beam --root "$tmp6" stats > /dev/null + beam --root "$tmp6" shutdown > /dev/null rm -f "$save_out" "$save_err" ) (cd "$tmp5" && lake_build SaveSmoke/A.lean > /dev/null) printf 'def bVal : Nat := "broken"\n' > "$tmp5/SaveSmoke/B.lean" +beam_start_owner "$tmp5" ( cd "$tmp5" - beam --root "$tmp5" shutdown > /dev/null 2>&1 || true - beam --root "$tmp5" ensure lean > /dev/null sync_out="$(mktemp /tmp/beam-stale-sync-out-XXXXXX)" sync_err="$(mktemp /tmp/beam-stale-sync-err-XXXXXX)" save_out="$(mktemp /tmp/beam-stale-save-out-XXXXXX)" @@ -779,5 +808,6 @@ printf 'def bVal : Nat := "broken"\n' > "$tmp5/SaveSmoke/B.lean" exit 1 fi beam --root "$tmp5" stats > /dev/null + beam --root "$tmp5" shutdown > /dev/null rm -f "$sync_out" "$sync_err" "$save_out" "$save_err" ) diff --git a/tests/test-beam-toolchain-compat.sh b/tests/test-beam-toolchain-compat.sh index 47cf0eeb..44e729eb 100644 --- a/tests/test-beam-toolchain-compat.sh +++ b/tests/test-beam-toolchain-compat.sh @@ -11,6 +11,8 @@ cd "$(dirname "$0")/.." . tests/lib/ci-steps.sh # shellcheck source=tests/lib/tmp-guards.sh . tests/lib/tmp-guards.sh +# shellcheck source=tests/lib/wait.sh +. tests/lib/wait.sh BEAM_TEST_SUITE="${BEAM_TEST_SUITE:-beam-toolchain-compat}" bundle_timeout="${BEAM_TOOLCHAIN_COMPAT_TIMEOUT:-600}" @@ -40,6 +42,9 @@ stale_build_stdout="$tmp_env_root/stale-build.stdout" stale_build_stderr="$tmp_env_root/stale-build.stderr" stale_sync_stdout="$tmp_env_root/stale-sync.stdout" stale_sync_stderr="$tmp_env_root/stale-sync.stderr" +stale_owner_stdout="$tmp_env_root/stale-owner.stdout" +stale_owner_stderr="$tmp_env_root/stale-owner.stderr" +stale_owner_pid="" toolchain_failed=false if [ -z "${ELAN_HOME:-}" ] && [ -d "$HOME/.elan" ]; then @@ -57,6 +62,12 @@ cleanup() { ELAN_HOME="$host_elan_home" BEAM_INSTALL_BUNDLE_DIR="$tmp_bundle_dir" \ ./scripts/lean-beam --root "$stale_project_root" shutdown > /dev/null 2>&1 || true fi + if [ -n "${stale_owner_pid:-}" ]; then + if kill -0 "$stale_owner_pid" 2>/dev/null; then + kill -INT "$stale_owner_pid" 2>/dev/null || true + fi + wait "$stale_owner_pid" 2>/dev/null || true + fi if [ "$toolchain_failed" = "true" ]; then case "$keep_tmp_on_failure" in 1|true|True|TRUE|yes|Yes|YES|on|On|ON) @@ -109,6 +120,8 @@ print_toolchain_context() { tail_file "stale build stderr" "$stale_build_stderr" tail_file "stale sync stdout" "$stale_sync_stdout" tail_file "stale sync stderr" "$stale_sync_stderr" + tail_file "stale owner stdout" "$stale_owner_stdout" + tail_file "stale owner stderr" "$stale_owner_stderr" } run_build() { @@ -152,7 +165,7 @@ PY run_bundle_install() { local rc=0 ( - unset BEAM_HOME BEAM_CONTROL_DIR + unset BEAM_HOME BEAM_CONTROL_ROOT export HOME="$tmp_env_root/home" export CODEX_HOME="$tmp_env_root/codex" export CLAUDE_HOME="$tmp_env_root/claude" @@ -206,6 +219,7 @@ prepare_stale_diagnostic_project() { printf '%s\n' "$toolchain" > "$stale_project_root/lean-toolchain" rm -rf -- "$stale_project_root/.beam" mkdir -p "$stale_project_root/.beam" + chmod 700 "$stale_project_root/.beam" } assert_stale_diagnostic_payload() { @@ -260,6 +274,35 @@ run_stale_wrapper_checked() { fi } +start_stale_owner() { + run_stale_wrapper ensure --hold > "$stale_owner_stdout" 2> "$stale_owner_stderr" & + stale_owner_pid="$!" + if ! wait_for_file_text "$stale_owner_stderr" "owning Beam session" \ + "toolchain compatibility session owner" 600 0.1; then + print_toolchain_context "explicit session owner failed to start" + return 1 + fi +} + +stop_stale_owner() { + if [ -z "$stale_owner_pid" ]; then + return 0 + fi + if ! run_stale_wrapper shutdown > /dev/null 2> "$stale_sync_stderr"; then + print_toolchain_context "explicit session owner failed to shut down" + return 1 + fi + if ! wait_for_exit "$stale_owner_pid" "toolchain compatibility session owner" 120 0.1; then + print_toolchain_context "explicit session owner did not exit" + return 1 + fi + if ! wait "$stale_owner_pid"; then + print_toolchain_context "explicit session owner exited unsuccessfully" + return 1 + fi + stale_owner_pid="" +} + run_stale_diagnostic_compat() { local rc=0 prepare_stale_diagnostic_project @@ -280,6 +323,10 @@ EOF return "$rc" fi + if ! start_stale_owner; then + return 1 + fi + if ! run_stale_wrapper_checked "initial dependency sync failed" sync SaveSmoke/B.lean; then return 1 fi @@ -320,6 +367,7 @@ EOF print_toolchain_context "stale diagnostic wording changed" return 1 fi + stop_stale_owner } run_step "build beam-cli" run_build diff --git a/tests/test-beam-wrapper-daemon.sh b/tests/test-beam-wrapper-daemon.sh index 14550ffb..ba5d5de9 100644 --- a/tests/test-beam-wrapper-daemon.sh +++ b/tests/test-beam-wrapper-daemon.sh @@ -23,66 +23,127 @@ if [ ! -x "$beam_cli" ]; then exit 1 fi -stop_hold_process() { - local require_clean_exit="${1:-false}" - if [ -n "$hold_pid" ]; then - kill -INT "$hold_pid" > /dev/null 2>&1 || true - if ! wait_for_exit "$hold_pid" "ensure --hold wrapper" 20 0.1; then - kill "$hold_pid" > /dev/null 2>&1 || true - wait "$hold_pid" 2>/dev/null || true - hold_pid="" - if [ "$require_clean_exit" = "true" ]; then - echo "expected ensure --hold wrapper to exit promptly after SIGINT" >&2 - return 1 - fi - else - local hold_status=0 - set +e - wait "$hold_pid" 2>/dev/null - hold_status="$?" - set -e - hold_pid="" - if [ "$require_clean_exit" = "true" ] && [ "$hold_status" -ne 0 ]; then - echo "expected ensure --hold wrapper to exit cleanly after SIGINT, got $hold_status" >&2 - return 1 - fi - fi - hold_pid="" - fi -} - -tmp1="$(mktemp -d /tmp/beam-wrapper-daemon-a-XXXXXX)" -tmp3="$(mktemp -d /tmp/beam-wrapper-daemon-c-XXXXXX)" -tmp9="$(mktemp -d /tmp/beam-wrapper-daemon-i-XXXXXX)" +tmp1="$(mktemp -d /tmp/beam-wrapper-daemon-owner-a-XXXXXX)" +tmp2="$(mktemp -d /tmp/beam-wrapper-daemon-owner-b-XXXXXX)" owned_bundle_dir="" if [ -z "${BEAM_INSTALL_BUNDLE_DIR:-}" ]; then - owned_bundle_dir="$(mktemp -d /tmp/beam-wrapper-daemon-bundles-XXXXXX)" + owned_bundle_dir="$(mktemp -d /tmp/beam-wrapper-daemon-owner-bundles-XXXXXX)" export BEAM_INSTALL_BUNDLE_DIR="$owned_bundle_dir" fi -busy_pid="" hold_pid="" -removed_root_pid="" -removed_root_err="" +root_removed="false" +active_request_pid="" +paused_daemon_pid="" +busy_pid="" +busy_port_file="" + +file_mode() { + case "$(uname -s)" in + Darwin) stat -f '%Lp' "$1" ;; + *) stat -c '%a' "$1" ;; + esac +} + +start_slow_request() { + local root="$1" + local label="$2" + local request_id="$3" + local version + version="$(beam_wrapper_update_version "$label SlowPoll" \ + "$beam_script" --root "$root" lean-update tests/scenario/docs/SlowPoll.lean)" + BEAM_PROGRESS=1 BEAM_REQUEST_ID="$request_id" "$beam_script" --root "$root" \ + lean-run-at tests/scenario/docs/SlowPoll.lean "$version" 25 2 poll_sleep_cmd \ + >"$root/$label.out" 2>"$root/$label.err" & + active_request_pid="$!" + if ! wait_for_file_text "$root/$label.err" "running lean-run-at" \ + "$label request progress" 150 0.1; then + cat "$root/$label.out" >&2 + cat "$root/$label.err" >&2 + exit 1 + fi + if ! kill -0 "$active_request_pid" 2>/dev/null; then + echo "expected $label request to remain active before session close" >&2 + cat "$root/$label.out" >&2 + cat "$root/$label.err" >&2 + exit 1 + fi +} + +expect_slow_request_cancelled() { + local root="$1" + local label="$2" + local request_id="$3" + local status=0 + set +e + wait "$active_request_pid" + status="$?" + set -e + active_request_pid="" + if [ "$status" -eq 0 ]; then + echo "expected $label request to exit non-zero after session close" >&2 + cat "$root/$label.out" >&2 + exit 1 + fi + assert_json_file_field_equals "$label cancellation code" "$root/$label.out" \ + error.code requestCancelled "$root/$label.err" + assert_json_file_field_equals "$label request id" "$root/$label.out" \ + clientRequestId "$request_id" "$root/$label.err" +} + +stop_hold_process() { + local require_clean_exit="${1:-false}" + if [ -z "$hold_pid" ]; then + return + fi + kill -INT "$hold_pid" > /dev/null 2>&1 || true + if ! wait_for_exit "$hold_pid" "ensure --hold owner" 200 0.05; then + kill "$hold_pid" > /dev/null 2>&1 || true + wait "$hold_pid" 2>/dev/null || true + hold_pid="" + if [ "$require_clean_exit" = "true" ]; then + echo "expected ensure --hold owner to exit promptly after SIGINT" >&2 + return 1 + fi + return + fi + local status=0 + set +e + wait "$hold_pid" + status="$?" + set -e + hold_pid="" + if [ "$require_clean_exit" = "true" ] && [ "$status" -ne 0 ]; then + echo "expected ensure --hold owner to exit cleanly, got $status" >&2 + return 1 + fi +} cleanup() { - stop_hold_process if [ -n "$busy_pid" ]; then kill "$busy_pid" > /dev/null 2>&1 || true wait "$busy_pid" 2>/dev/null || true + busy_pid="" fi - if [ -n "$removed_root_pid" ]; then - kill "$removed_root_pid" > /dev/null 2>&1 || true - wait "$removed_root_pid" 2>/dev/null || true + if [ -n "$busy_port_file" ]; then + rm -f -- "$busy_port_file" + busy_port_file="" fi - if [ -n "$removed_root_err" ]; then - rm -f "$removed_root_err" + if [ -n "$paused_daemon_pid" ]; then + kill -CONT "$paused_daemon_pid" > /dev/null 2>&1 || true + paused_daemon_pid="" fi - "$beam_script" --root "$tmp1" shutdown > /dev/null 2>&1 || true - "$beam_script" --root "$tmp3" shutdown > /dev/null 2>&1 || true - "$beam_script" --root "$tmp9" shutdown > /dev/null 2>&1 || true - remove_owned_tmp_tree "$tmp1" - remove_owned_tmp_tree "$tmp3" - remove_owned_tmp_tree "$tmp9" + if [ -n "$active_request_pid" ]; then + kill "$active_request_pid" > /dev/null 2>&1 || true + wait "$active_request_pid" 2>/dev/null || true + active_request_pid="" + fi + stop_hold_process + if [ "$root_removed" != "true" ]; then + "$beam_script" --root "$tmp1" shutdown > /dev/null 2>&1 || true + remove_owned_tmp_tree "$tmp1" + fi + "$beam_script" --root "$tmp2" shutdown > /dev/null 2>&1 || true + remove_owned_tmp_tree "$tmp2" if [ -n "$owned_bundle_dir" ]; then remove_owned_tmp_tree "$owned_bundle_dir" fi @@ -92,238 +153,938 @@ trap cleanup EXIT if [ -n "$owned_bundle_dir" ]; then expect_owned_tmp_dir "$owned_bundle_dir" fi - -fixture_toolchain="$(awk 'NR==1 {print $1}' tests/save_olean_project/lean-toolchain)" -"$beam_cli" bundle-install "$fixture_toolchain" - -for tmp in "$tmp1" "$tmp3" "$tmp9"; do +for tmp in "$tmp1" "$tmp2"; do expect_owned_tmp_dir "$tmp" rsync -a --exclude='.beam/' tests/save_olean_project/ "$tmp"/ remove_tmp_tree_within "$tmp/.beam" "$tmp" mkdir -p "$tmp/.beam" + chmod 700 "$tmp/.beam" + mkdir -p "$tmp/tests/scenario/docs" + cp tests/scenario/docs/SlowPoll.lean "$tmp/tests/scenario/docs/SlowPoll.lean" done -"$beam_script" --root "$tmp9" ensure --hold > "$tmp9/hold.out" 2> "$tmp9/hold.err" & -hold_pid="$!" -hold_registry="$tmp9/.beam/beam-daemon.json" -# A cold installed-bundle qualification can build the selected Lean payload before `ensure` -# responds. Keep this bounded, but allow enough time for that legitimate first-use path on CI. -hold_ready_attempts="${BEAM_TEST_HOLD_READY_ATTEMPTS:-1800}" -case "$hold_ready_attempts" in +fixture_toolchain="$(awk 'NR==1 {print $1}' tests/save_olean_project/lean-toolchain)" +"$beam_cli" bundle-install "$fixture_toolchain" + +invalid_backend_out="$tmp1/invalid-backend.out" +invalid_backend_err="$tmp1/invalid-backend.err" +if "$beam_script" --root "$tmp1" ensure typo --hold > "$invalid_backend_out" 2> "$invalid_backend_err"; then + echo "expected an unknown owner backend to be rejected" >&2 + cat "$invalid_backend_out" >&2 + exit 1 +fi +if ! grep -Fq "expected backend 'lean' or 'rocq'" "$invalid_backend_err"; then + echo "expected unknown-backend diagnostics to list the valid backend names" >&2 + cat "$invalid_backend_err" >&2 + exit 1 +fi + +# An attaching command observes descriptor state but does not acquire the mutation lock or create +# control-plane files when no owner exists. +remove_tmp_tree_within "$tmp1/.beam" "$tmp1" +missing_owner_out="$tmp1/missing-owner.out" +missing_owner_err="$tmp1/missing-owner.err" +if "$beam_script" --root "$tmp1" ensure > "$missing_owner_out" 2> "$missing_owner_err"; then + echo "expected an ordinary wrapper command to require a session owner" >&2 + cat "$missing_owner_out" >&2 + exit 1 +fi +if ! grep -Fq "lean-beam ensure --hold" "$missing_owner_err"; then + echo "expected missing-owner error to name the recovery command" >&2 + cat "$missing_owner_err" >&2 + exit 1 +fi +if [ -e "$tmp1/.beam" ]; then + echo "expected missing-owner attachment not to create the project control directory" >&2 + find "$tmp1/.beam" -maxdepth 2 -print >&2 || true + exit 1 +fi + +# A control path is an exact security boundary, not a redirect. Reject a symlinked default path +# without changing the target or creating any control files through it. +symlink_control_target="$tmp2/symlink-control-target" +mkdir -p "$symlink_control_target" +chmod 755 "$symlink_control_target" +symlink_target_mode_before="$(file_mode "$symlink_control_target")" +ln -s "$symlink_control_target" "$tmp1/.beam" +if "$beam_script" --root "$tmp1" recover --force \ + > "$tmp1/symlink-control.out" 2> "$tmp1/symlink-control.err"; then + echo "expected a symlinked default control directory to be rejected" >&2 + exit 1 +fi +if ! grep -Fq "symbolic links are not accepted" "$tmp1/symlink-control.err"; then + echo "expected symlinked control rejection to explain the exact-path boundary" >&2 + cat "$tmp1/symlink-control.err" >&2 + exit 1 +fi +if [ "$(file_mode "$symlink_control_target")" != "$symlink_target_mode_before" ]; then + echo "symlinked control rejection changed the target directory mode" >&2 + exit 1 +fi +if find "$symlink_control_target" -mindepth 1 -print -quit | grep -q .; then + echo "symlinked control rejection created files in the target directory" >&2 + find "$symlink_control_target" -mindepth 1 -maxdepth 2 -print >&2 + exit 1 +fi +rm -f -- "$tmp1/.beam" +rmdir "$symlink_control_target" + +start_owner() { + local root="$1" + local label="$2" + local out="$root/$label.out" + local err="$root/$label.err" + "$beam_script" --root "$root" ensure --hold > "$out" 2> "$err" & + hold_pid="$!" + local registry="$root/.beam/beam-daemon.json" + local attempts="${BEAM_TEST_HOLD_READY_ATTEMPTS:-1800}" + case "$attempts" in + ''|*[!0-9]*|0) + echo "BEAM_TEST_HOLD_READY_ATTEMPTS must be a positive integer" >&2 + exit 1 + ;; + esac + for _ in $(seq 1 "$attempts"); do + if [ -s "$out" ] && [ -f "$registry" ]; then + break + fi + if ! kill -0 "$hold_pid" 2>/dev/null; then + echo "session owner exited before becoming ready" >&2 + cat "$err" >&2 + exit 1 + fi + sleep 0.1 + done + if [ ! -s "$out" ] || [ ! -f "$registry" ]; then + echo "expected ensure --hold to publish its response and registry" >&2 + cat "$err" >&2 + exit 1 + fi + assert_json_field_equals "ensure --hold response" "$(cat "$out")" ok true "$err" +} + +registry="$tmp1/.beam/beam-daemon.json" +start_owner "$tmp1" "owner-1" +owner1_pid="$hold_pid" +daemon1_pid="$(read_json_field "$registry" pid)" +daemon1_id="$(read_json_field "$registry" daemonId)" +recorded_owner_pid="$(read_json_field "$registry" ownerPid)" +case "$recorded_owner_pid" in ''|*[!0-9]*|0) - echo "BEAM_TEST_HOLD_READY_ATTEMPTS must be a positive integer" >&2 + echo "expected registry to record a positive session-owner PID" >&2 + cat "$registry" >&2 exit 1 ;; esac -for _ in $(seq 1 "$hold_ready_attempts"); do - if [ -s "$tmp9/hold.out" ] && [ -f "$hold_registry" ]; then - break - fi - sleep 0.1 -done -if [ ! -s "$tmp9/hold.out" ] || [ ! -f "$hold_registry" ]; then - echo "expected ensure --hold to print an ensure response and create a registry after $hold_ready_attempts readiness probes" >&2 - cat "$tmp9/hold.err" >&2 +if ! kill -0 "$owner1_pid" 2>/dev/null || ! kill -0 "$daemon1_pid" 2>/dev/null; then + echo "expected both the wrapper owner and daemon to remain alive" >&2 exit 1 fi -if ! kill -0 "$hold_pid" 2>/dev/null; then - echo "expected ensure --hold wrapper process to remain alive" >&2 - cat "$tmp9/hold.err" >&2 + +ensure_json="$("$beam_script" --root "$tmp1" ensure)" +assert_json_field_equals "owned ensure response" "$ensure_json" ok true +stats_json="$("$beam_script" --root "$tmp1" stats)" +assert_json_field_equals "owned stats response" "$stats_json" ok true + +machine_stats_json="$("$beam_script" --root "$tmp1" request-stream \ + '{"op":"stats","clientRequestId":"machine-stats"}')" +assert_json_field_equals "root-aware machine stream kind" "$machine_stats_json" kind response +assert_json_field_equals \ + "root-aware machine stream request id" "$machine_stats_json" clientRequestId machine-stats +assert_json_field_equals \ + "root-aware machine stream response" "$machine_stats_json" payload.ok true +if "$beam_script" request-stream '{"op":"stats","clientRequestId":"missing-root"}' \ + > "$tmp1/machine-missing-root.out" 2> "$tmp1/machine-missing-root.err"; then + echo "expected the machine stream interface to require --root" >&2 + exit 1 +fi +if ! grep -Fq "requires an explicit --root PATH" "$tmp1/machine-missing-root.err"; then + echo "expected missing-root machine diagnostics to explain the explicit selector" >&2 + cat "$tmp1/machine-missing-root.err" >&2 + exit 1 +fi +if "$beam_script" --root "$tmp1" request-stream \ + '{"op":"stats","clientRequestId":"caller-route","workspaceId":"beam-cli-project"}' \ + > "$tmp1/machine-route.out" 2> "$tmp1/machine-route.err"; then + echo "expected machine requests to reject caller-selected session routing" >&2 + exit 1 +fi +if ! grep -Fq "session-owned fields: workspaceId" "$tmp1/machine-route.err"; then + echo "expected machine routing rejection to name the forbidden field" >&2 + cat "$tmp1/machine-route.err" >&2 + exit 1 +fi +if "$beam_script" --root "$tmp1" request-stream \ + '{"op":"shutdown","clientRequestId":"machine-shutdown"}' \ + > "$tmp1/machine-shutdown.out" 2> "$tmp1/machine-shutdown.err"; then + echo "expected semantic machine requests not to expose process-wide shutdown" >&2 + exit 1 +fi +if ! grep -Fq "not available through a project session" "$tmp1/machine-shutdown.err"; then + echo "expected machine shutdown rejection to explain the project-session boundary" >&2 + cat "$tmp1/machine-shutdown.err" >&2 + exit 1 +fi +machine_after_shutdown_json="$("$beam_script" --root "$tmp1" request-stream \ + '{"op":"stats","clientRequestId":"machine-after-shutdown"}')" +assert_json_field_equals \ + "rejected machine shutdown leaves daemon live" "$machine_after_shutdown_json" payload.ok true +if ! kill -0 "$daemon1_pid" 2>/dev/null || \ + [ "$(read_json_field "$registry" daemonId)" != "$daemon1_id" ]; then + echo "expected rejected machine shutdown to preserve the selected generation" >&2 exit 1 fi -hold_json="$(cat "$tmp9/hold.out")" -assert_json_field_equals "ensure --hold response" "$hold_json" ok true "$tmp9/hold.err" -stop_hold_process true -"$beam_script" --root "$tmp9" shutdown > /dev/null -stale_lease_dir="$tmp9/.beam/wrapper-leases" -stale_lease="$stale_lease_dir/stale-dead-wrapper.lease" -mkdir -p "$stale_lease_dir" -pid_namespace="$(readlink /proc/self/ns/pid 2>/dev/null || true)" -LEASE_PATH="$stale_lease" PID_NAMESPACE="$pid_namespace" python3 - <<'PY' -import json, os +python3 - "$registry" "$tmp1" <<'PY' +import json +import os +import sys -metadata = { - "pid": 999999999, - "pidNamespace": os.environ["PID_NAMESPACE"] or None, - "createdAt": "test", -} -with open(os.environ["LEASE_PATH"], "w") as f: - json.dump(metadata, f) - f.write("\n") +registry, root = sys.argv[1:] +with open(registry, encoding="utf-8") as stream: + entry = json.load(stream) +if entry.get("schemaVersion") != 2: + raise SystemExit(f"unexpected session schema: {entry.get('schemaVersion')}") +workspaces = entry.get("workspaces") +if not isinstance(workspaces, list) or len(workspaces) != 1: + raise SystemExit(f"unexpected workspace bindings: {workspaces!r}") +workspace = workspaces[0] +if workspace.get("root") != os.path.realpath(root): + raise SystemExit(f"unexpected workspace root: {workspace!r}") +if workspace.get("workspaceId") != "beam-cli-project": + raise SystemExit(f"unexpected workspace id: {workspace!r}") PY -"$beam_script" --root "$tmp9" ensure lean > /dev/null -if [ -e "$stale_lease" ]; then - echo "expected wrapper ensure to remove a stale same-namespace wrapper lease" >&2 - cat "$stale_lease" >&2 +control_dir_mode="$(file_mode "$tmp1/.beam")" +registry_mode="$(file_mode "$registry")" +if [ "$control_dir_mode" != "700" ]; then + echo "expected the capability control directory to use mode 700, got $control_dir_mode" >&2 + exit 1 +fi +if [ "$registry_mode" != "600" ]; then + echo "expected the capability-bearing registry to use mode 600, got $registry_mode" >&2 exit 1 fi -"$beam_script" --root "$tmp9" shutdown > /dev/null -( - cd "$tmp1" - "$beam_script" ensure lean > /dev/null -) +cross_root_descriptor="$tmp2/cross-root-recovery.before" +cp -- "$registry" "$cross_root_descriptor" +if "$beam_script" --root "$tmp2" --control-dir "$tmp1/.beam" \ + recover --generation "$daemon1_id" \ + > "$tmp2/cross-root-recovery.out" 2> "$tmp2/cross-root-recovery.err"; then + echo "expected recovery through a non-member root to fail closed" >&2 + exit 1 +fi +if ! grep -Fq "is not a workspace in session $daemon1_id" \ + "$tmp2/cross-root-recovery.err" || \ + ! grep -Fq "$tmp1" "$tmp2/cross-root-recovery.err"; then + echo "expected cross-root recovery rejection to name the session and its recorded root" >&2 + cat "$tmp2/cross-root-recovery.err" >&2 + exit 1 +fi +if ! cmp -s -- "$cross_root_descriptor" "$registry"; then + echo "cross-root recovery must preserve the descriptor byte-for-byte" >&2 + exit 1 +fi +cross_root_stats_json="$("$beam_script" --root "$tmp1" stats)" +assert_json_field_equals "stats after rejected cross-root recovery" "$cross_root_stats_json" ok true +if ! kill -0 "$owner1_pid" 2>/dev/null || ! kill -0 "$daemon1_pid" 2>/dev/null; then + echo "cross-root recovery rejection must preserve the live owner and daemon" >&2 + exit 1 +fi -reg1="$tmp1/.beam/beam-daemon.json" -expect_file "$reg1" +if "$beam_script" --root "$tmp1" recover --generation "$daemon1_id" \ + > "$tmp1/live-recover.out" 2> "$tmp1/live-recover.err"; then + echo "expected explicit recovery to refuse a responding generation" >&2 + exit 1 +fi +if ! grep -Fq "still responds" "$tmp1/live-recover.err"; then + echo "expected live-generation recovery refusal to explain the active endpoint" >&2 + cat "$tmp1/live-recover.err" >&2 + exit 1 +fi +if [ "$(read_json_field "$registry" daemonId)" != "$daemon1_id" ] || \ + ! kill -0 "$owner1_pid" 2>/dev/null || ! kill -0 "$daemon1_pid" 2>/dev/null; then + echo "live-generation recovery refusal must preserve the owner and descriptor" >&2 + exit 1 +fi -pid1="$(read_json_field "$reg1" pid)" -port1="$(read_json_field "$reg1" port)" -root1="$(read_json_field "$reg1" root)" -if [ "$root1" != "$(beam_test_realpath "$tmp1")" ]; then - echo "wrapper registry root mismatch: expected $tmp1, got $root1" >&2 +if BEAM_CONTROL_ROOT=relative-control-root \ + "$beam_script" --root "$tmp1" stats \ + > "$tmp1/relative-control-root.out" 2> "$tmp1/relative-control-root.err"; then + echo "expected relative BEAM_CONTROL_ROOT to be rejected" >&2 exit 1 fi -if ! kill -0 "$pid1" 2>/dev/null; then - echo "expected Beam daemon pid $pid1 to be alive" >&2 +if ! grep -Fq "BEAM_CONTROL_ROOT must be an absolute path" \ + "$tmp1/relative-control-root.err"; then + echo "expected relative BEAM_CONTROL_ROOT rejection to explain the stable-path requirement" >&2 + cat "$tmp1/relative-control-root.err" >&2 exit 1 fi -( - cd "$tmp3" - collision_out="$(mktemp /tmp/beam-wrapper-port-collision-out-XXXXXX)" - collision_err="$(mktemp /tmp/beam-wrapper-port-collision-err-XXXXXX)" - if "$beam_script" --port "$port1" ensure lean >"$collision_out" 2>"$collision_err"; then - echo "expected wrapper ensure to reject a port already serving another Beam root" >&2 - cat "$collision_out" >&2 - cat "$collision_err" >&2 - rm -f "$collision_out" "$collision_err" - exit 1 - fi - if ! grep -q 'already serves Beam root' "$collision_err"; then - echo "expected port collision failure to name the existing Beam root" >&2 - cat "$collision_out" >&2 - cat "$collision_err" >&2 - rm -f "$collision_out" "$collision_err" - exit 1 - fi - if [ -f "$tmp3/.beam/beam-daemon.json" ]; then - echo "expected port collision failure not to write a registry for the wrong endpoint" >&2 - cat "$tmp3/.beam/beam-daemon.json" >&2 - rm -f "$collision_out" "$collision_err" - exit 1 - fi - rm -f "$collision_out" "$collision_err" -) +port1="$(read_json_field "$registry" port)" +python3 - "$port1" <<'PY' +import json +import socket +import sys +import time + +port = int(sys.argv[1]) + +def receive_frame(sock): + header = bytearray() + while not header.endswith(b"\n"): + chunk = sock.recv(1) + if not chunk: + raise RuntimeError("daemon closed before returning a framed error") + header.extend(chunk) + size = int(header[:-1]) + payload = bytearray() + while len(payload) < size: + chunk = sock.recv(size - len(payload)) + if not chunk: + raise RuntimeError("daemon closed during its framed error") + payload.extend(chunk) + return json.loads(payload) + +with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: + request = json.dumps({ + "op": "shutdown", + "daemonCapability": "not-the-owner-capability", + }).encode() + sock.sendall(str(len(request)).encode() + b"\n" + request) + response = receive_frame(sock) + payload = response.get("payload", {}) + if payload.get("ok") is not False: + raise RuntimeError(f"unauthorized shutdown unexpectedly succeeded: {response}") + message = payload.get("error", {}).get("message", "") + if "invalid Beam daemon capability" not in message: + raise RuntimeError(f"unexpected unauthorized-shutdown response: {response}") + +with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: + sock.sendall(b"16777217\n") + response = receive_frame(sock) + if "exceeds 16777216 bytes" not in response.get("payload", {}).get("error", {}).get("message", ""): + raise RuntimeError(f"unexpected oversized-frame response: {response}") + +with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: + time.sleep(5.5) + response = receive_frame(sock) + if "initial request timed out" not in response.get("payload", {}).get("error", {}).get("message", ""): + raise RuntimeError(f"unexpected first-message-timeout response: {response}") +PY + +stats_after_security_probes_json="$("$beam_script" --root "$tmp1" stats)" +assert_json_field_equals \ + "stats after unauthorized shutdown and transport limit probes" \ + "$stats_after_security_probes_json" ok true +if ! kill -0 "$owner1_pid" 2>/dev/null || ! kill -0 "$daemon1_pid" 2>/dev/null; then + echo "expected unauthorized shutdown to preserve both owner and daemon" >&2 + exit 1 +fi + +second_owner_out="$tmp1/second-owner.out" +second_owner_err="$tmp1/second-owner.err" +if "$beam_script" --root "$tmp1" ensure --hold > "$second_owner_out" 2> "$second_owner_err"; then + echo "expected a second foreground owner to be rejected" >&2 + cat "$second_owner_out" >&2 + exit 1 +fi +if ! grep -Fq "already owned" "$second_owner_err"; then + echo "expected duplicate-owner failure to identify the active owner" >&2 + cat "$second_owner_err" >&2 + exit 1 +fi -stale_registry="$tmp3/.beam/beam-daemon.json" -REGISTRY_TEMPLATE="$reg1" STALE_REGISTRY="$stale_registry" STALE_ROOT="$tmp3" python3 - <<'PY' +collision_out="$tmp2/collision.out" +collision_err="$tmp2/collision.err" +if "$beam_script" --root "$tmp2" --port "$port1" ensure --hold > "$collision_out" 2> "$collision_err"; then + echo "expected an owner not to claim another project's endpoint" >&2 + cat "$collision_out" >&2 + exit 1 +fi +if ! grep -Fq "invalid Beam daemon capability" "$collision_err"; then + echo "expected endpoint collision not to disclose an authenticated daemon's project" >&2 + cat "$collision_err" >&2 + exit 1 +fi +if [ -e "$tmp2/.beam/beam-daemon.json" ]; then + echo "expected endpoint collision not to publish a registry" >&2 + cat "$tmp2/.beam/beam-daemon.json" >&2 + exit 1 +fi + +stale_registry="$tmp2/.beam/beam-daemon.json" +REGISTRY_TEMPLATE="$registry" STALE_REGISTRY="$stale_registry" STALE_ROOT="$tmp2" python3 - <<'PY' import json import os -with open(os.environ["REGISTRY_TEMPLATE"]) as f: - data = json.load(f) -data["root"] = os.path.realpath(os.environ["STALE_ROOT"]) -data["configHash"] = "stale-registry-test" -with open(os.environ["STALE_REGISTRY"], "w") as f: - json.dump(data, f) - f.write("\n") +with open(os.environ["REGISTRY_TEMPLATE"], encoding="utf-8") as stream: + entry = json.load(stream) +entry["workspaces"][0]["root"] = os.path.realpath(os.environ["STALE_ROOT"]) +replacement = os.environ["STALE_REGISTRY"] + ".replacement" +with open(replacement, "w", encoding="utf-8") as stream: + json.dump(entry, stream, separators=(",", ":")) + stream.write("\n") +os.replace(replacement, os.environ["STALE_REGISTRY"]) PY +stale_shutdown_out="$tmp2/stale-cross-root-shutdown.out" +stale_shutdown_err="$tmp2/stale-cross-root-shutdown.err" +if "$beam_script" --root "$tmp2" shutdown \ + > "$stale_shutdown_out" 2> "$stale_shutdown_err"; then + echo "expected shutdown to reject a registry whose endpoint serves another root" >&2 + cat "$stale_shutdown_out" >&2 + exit 1 +fi +if ! grep -Fq "serves another root" "$stale_shutdown_err"; then + echo "expected cross-root registry rejection to explain the identity mismatch" >&2 + cat "$stale_shutdown_err" >&2 + exit 1 +fi +if [ ! -e "$stale_registry" ]; then + echo "cross-root registry rejection must preserve the unsafe registry as recovery evidence" >&2 + exit 1 +fi +if ! kill -0 "$owner1_pid" 2>/dev/null || ! kill -0 "$daemon1_pid" 2>/dev/null; then + echo "cross-root registry rejection must not stop the daemon or owner serving the other root" >&2 + exit 1 +fi +rm -f -- "$stale_registry" -( - cd "$tmp3" - doctor_out="$("$beam_script" doctor lean)" - if ! printf '%s\n' "$doctor_out" | grep -q 'daemon status: stale'; then - echo "expected wrapper doctor to reject a stale registry whose endpoint serves another root" >&2 - printf '%s\n' "$doctor_out" >&2 - exit 1 - fi - "$beam_script" shutdown > /dev/null - if [ -f "$stale_registry" ]; then - echo "expected wrapper shutdown to remove the stale registry" >&2 - cat "$stale_registry" >&2 - exit 1 - fi -) -if ! kill -0 "$pid1" 2>/dev/null; then - echo "expected stale registry shutdown not to kill the real Beam daemon for tmp1" >&2 +LEGACY_REGISTRY="$stale_registry" LEGACY_ROOT="$tmp2" python3 - <<'PY' +import json +import os + +entry = { + "daemonId": "legacy-generation", + "pid": 999999999, + "ownerPid": 999999999, + "port": 42424, + "root": os.path.realpath(os.environ["LEGACY_ROOT"]), + "configHash": "legacy-config", + "startedAt": "2026-08-27T00:00:00Z", +} +with open(os.environ["LEGACY_REGISTRY"], "w", encoding="utf-8") as stream: + json.dump(entry, stream, separators=(",", ":")) + stream.write("\n") +PY +legacy_before="$(cat "$stale_registry")" +legacy_owner_out="$tmp2/legacy-owner.out" +legacy_owner_err="$tmp2/legacy-owner.err" +if "$beam_script" --root "$tmp2" ensure --hold \ + > "$legacy_owner_out" 2> "$legacy_owner_err"; then + echo "expected owner startup to reject a schema-less legacy registry" >&2 + cat "$legacy_owner_out" >&2 + exit 1 +fi +if ! grep -Fq "legacy registry has no schemaVersion" "$legacy_owner_err"; then + echo "expected legacy-registry rejection to explain the unsupported schema" >&2 + cat "$legacy_owner_err" >&2 + exit 1 +fi +if [ "$(cat "$stale_registry")" != "$legacy_before" ]; then + echo "legacy-registry rejection must preserve the recovery evidence" >&2 + cat "$stale_registry" >&2 + exit 1 +fi +legacy_recover_json="$("$beam_script" --root "$tmp2" recover --force)" +assert_json_field_equals "opaque registry recovery" "$legacy_recover_json" recovered true +if [ -e "$stale_registry" ]; then + echo "expected explicit opaque recovery to quarantine the legacy descriptor" >&2 + exit 1 +fi +legacy_quarantine="$(json_text_field "$legacy_recover_json" quarantinedPath)" +if [ ! -f "$legacy_quarantine" ]; then + echo "expected opaque recovery to preserve quarantined evidence" >&2 + printf '%s\n' "$legacy_recover_json" >&2 + exit 1 +fi + +busy_port_file="$(mktemp "$tmp2/non-beam-port-XXXXXX")" +python3 - "$busy_port_file" <<'PY' & +import socketserver +import sys + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + self.request.recv(4096) + +with socketserver.TCPServer(("127.0.0.1", 0), Handler) as server: + with open(sys.argv[1], "w", encoding="utf-8") as stream: + print(server.server_address[1], file=stream, flush=True) + server.serve_forever() +PY +busy_pid="$!" +if ! wait_for_nonempty_file "$busy_port_file" "non-Beam occupied port"; then + exit 1 +fi +busy_port="$(cat "$busy_port_file")" +busy_out="$tmp2/non-beam-port.out" +busy_err="$tmp2/non-beam-port.err" +if "$beam_script" --root "$tmp2" --port "$busy_port" ensure --hold > "$busy_out" 2> "$busy_err"; then + echo "expected owner startup to reject a port occupied by a non-Beam service" >&2 + cat "$busy_out" >&2 + exit 1 +fi +if ! grep -Fq "already in use" "$busy_err"; then + echo "expected non-Beam port collision to report the occupied endpoint" >&2 + cat "$busy_err" >&2 + exit 1 +fi +if [ -e "$tmp2/.beam/beam-daemon.json" ]; then + echo "expected non-Beam port collision not to publish a registry" >&2 + cat "$tmp2/.beam/beam-daemon.json" >&2 exit 1 fi +kill "$busy_pid" > /dev/null 2>&1 || true +wait "$busy_pid" 2>/dev/null || true +busy_pid="" +rm -f -- "$busy_port_file" +busy_port_file="" -busy_port_file="$(mktemp /tmp/beam-wrapper-busy-port-XXXXXX)" +busy_port_file="$(mktemp "$tmp2/silent-non-beam-port-XXXXXX")" python3 - "$busy_port_file" <<'PY' & -import http.server import socketserver import sys +import time -class Handler(http.server.SimpleHTTPRequestHandler): - def log_message(self, format, *args): - pass +class Handler(socketserver.BaseRequestHandler): + def handle(self): + time.sleep(10) with socketserver.TCPServer(("127.0.0.1", 0), Handler) as server: - with open(sys.argv[1], "w") as f: - print(server.server_address[1], file=f, flush=True) + with open(sys.argv[1], "w", encoding="utf-8") as stream: + print(server.server_address[1], file=stream, flush=True) server.serve_forever() PY -busy_pid=$! -for _ in $(seq 1 100); do - if [ -s "$busy_port_file" ]; then +busy_pid="$!" +if ! wait_for_nonempty_file "$busy_port_file" "silent non-Beam occupied port"; then + exit 1 +fi +busy_port="$(cat "$busy_port_file")" +busy_out="$tmp2/silent-non-beam-port.out" +busy_err="$tmp2/silent-non-beam-port.err" +silent_probe_started="$SECONDS" +if "$beam_script" --root "$tmp2" --port "$busy_port" ensure --hold > "$busy_out" 2> "$busy_err"; then + echo "expected owner startup to reject a silent non-Beam service" >&2 + cat "$busy_out" >&2 + exit 1 +fi +silent_probe_elapsed="$((SECONDS - silent_probe_started))" +if [ "$silent_probe_elapsed" -ge 8 ]; then + echo "expected silent non-Beam probe to honor its response deadline, took ${silent_probe_elapsed}s" >&2 + cat "$busy_err" >&2 + exit 1 +fi +if ! grep -Fq "response timed out" "$busy_err"; then + echo "expected silent non-Beam collision to report the bounded probe timeout" >&2 + cat "$busy_err" >&2 + exit 1 +fi +if [ -e "$tmp2/.beam/beam-daemon.json" ]; then + echo "expected silent non-Beam collision not to publish a registry" >&2 + cat "$tmp2/.beam/beam-daemon.json" >&2 + exit 1 +fi +kill "$busy_pid" > /dev/null 2>&1 || true +wait "$busy_pid" 2>/dev/null || true +busy_pid="" +rm -f -- "$busy_port_file" +busy_port_file="" + +start_slow_request "$tmp1" "shutdown-active" "shutdown-active" + +# The desired owner configuration includes installed bundle paths. An ordinary attaching command +# must use the descriptor's frozen configuration without rebuilding a local desired hash. A second +# owner still computes its proposed configuration and reports the mismatch without disturbing the +# running generation. +drift_bundle_dir="$tmp2/config-drift-bundles" +mkdir -p "$drift_bundle_dir" +rsync -a "$BEAM_INSTALL_BUNDLE_DIR/" "$drift_bundle_dir/" +drift_out="$tmp1/config-drift.out" +drift_err="$tmp1/config-drift.err" +if ! BEAM_INSTALL_BUNDLE_DIR="$drift_bundle_dir" \ + "$beam_script" --root "$tmp1" ensure > "$drift_out" 2> "$drift_err"; then + echo "expected ordinary attachment to use the owner's frozen configuration" >&2 + cat "$drift_err" >&2 + exit 1 +fi +assert_json_file_field_equals \ + "frozen-configuration attachment" "$drift_out" ok true "$drift_err" +drift_owner_out="$tmp1/config-drift-owner.out" +drift_owner_err="$tmp1/config-drift-owner.err" +if BEAM_INSTALL_BUNDLE_DIR="$drift_bundle_dir" \ + "$beam_script" --root "$tmp1" ensure --hold \ + > "$drift_owner_out" 2> "$drift_owner_err"; then + echo "expected a mismatched replacement owner to be rejected" >&2 + cat "$drift_owner_out" >&2 + exit 1 +fi +if ! grep -Fq "current owner was preserved" "$drift_owner_err"; then + echo "expected replacement-owner drift diagnostics to preserve the current owner" >&2 + cat "$drift_owner_err" >&2 + exit 1 +fi +if [ -s "$drift_err" ]; then + echo "ordinary frozen-configuration attachment produced unexpected diagnostics" >&2 + cat "$drift_out" >&2 + cat "$drift_err" >&2 + exit 1 +fi +if [ "$(read_json_field "$registry" daemonId)" != "$daemon1_id" ] || \ + [ "$(read_json_field "$registry" pid)" != "$daemon1_pid" ]; then + echo "configuration-drift lookup changed the live daemon generation" >&2 + cat "$registry" >&2 + exit 1 +fi +if ! kill -0 "$owner1_pid" 2>/dev/null || ! kill -0 "$daemon1_pid" 2>/dev/null || \ + ! kill -0 "$active_request_pid" 2>/dev/null; then + echo "configuration-drift lookup terminated the owner, daemon, or active request" >&2 + exit 1 +fi + +shutdown_json="$("$beam_script" --root "$tmp1" shutdown)" +assert_json_field_equals "explicit session shutdown" "$shutdown_json" ok true +expect_slow_request_cancelled "$tmp1" "shutdown-active" "shutdown-active" +if ! wait_for_exit "$hold_pid" "owner after explicit shutdown" 200 0.05; then + cat "$tmp1/owner-1.err" >&2 + exit 1 +fi +set +e +wait "$hold_pid" +owner_status="$?" +set -e +hold_pid="" +if [ "$owner_status" -ne 0 ]; then + echo "expected owner to exit cleanly after explicit shutdown, got $owner_status" >&2 + cat "$tmp1/owner-1.err" >&2 + exit 1 +fi +if ! wait_for_exit "$daemon1_pid" "daemon after explicit session shutdown" 200 0.05; then + exit 1 +fi +if [ -e "$registry" ]; then + echo "expected owner shutdown to remove its registry" >&2 + cat "$registry" >&2 + exit 1 +fi + +start_owner "$tmp1" "owner-2" +daemon2_pid="$(read_json_field "$registry" pid)" +daemon2_id="$(read_json_field "$registry" daemonId)" +if [ "$daemon2_id" = "$daemon1_id" ]; then + echo "expected a new owner to publish a new daemon generation" >&2 + exit 1 +fi + +kill -KILL "$daemon2_pid" +if ! wait_for_exit "$hold_pid" "owner after unexpected daemon crash" 200 0.05; then + cat "$tmp1/owner-2.err" >&2 + exit 1 +fi +set +e +wait "$hold_pid" +crashed_owner_status="$?" +set -e +hold_pid="" +if [ "$crashed_owner_status" -eq 0 ]; then + echo "expected the owner to report an unexpected daemon crash" >&2 + exit 1 +fi +if ! grep -Fq "owned Beam daemon exited with status" "$tmp1/owner-2.err"; then + echo "expected the owner crash report to include the daemon exit status" >&2 + cat "$tmp1/owner-2.err" >&2 + exit 1 +fi +if [ ! -e "$registry" ]; then + echo "expected an unexpected daemon crash to preserve its exact session fence" >&2 + exit 1 +fi +if [ "$(read_json_field "$registry" daemonId)" != "$daemon2_id" ] || \ + [ "$(read_json_field "$registry" lifecycle)" != "draining" ]; then + echo "expected an unexpected daemon crash to leave its generation draining" >&2 + cat "$registry" >&2 + exit 1 +fi +if "$beam_script" --root "$tmp1" ensure --hold \ + > "$tmp1/crash-replacement.out" 2> "$tmp1/crash-replacement.err"; then + echo "expected crash-fenced state to reject a replacement owner" >&2 + exit 1 +fi +crash_recovery_json="$("$beam_script" --root "$tmp1" recover --generation "$daemon2_id")" +assert_json_field_equals "unexpected-crash recovery" "$crash_recovery_json" recovered true +if [ -e "$registry" ]; then + echo "expected exact-generation crash recovery to quarantine the fence" >&2 + exit 1 +fi + +start_owner "$tmp1" "owner-draining-fence" +draining_daemon_pid="$(read_json_field "$registry" pid)" +draining_daemon_id="$(read_json_field "$registry" daemonId)" +start_slow_request "$tmp1" "draining-process-tree" "draining-process-tree" +draining_backend_pids="$(pgrep -P "$draining_daemon_pid" || true)" +if [ -z "$draining_backend_pids" ]; then + echo "expected the active request to create a daemon-owned backend process" >&2 + exit 1 +fi +kill -STOP "$draining_daemon_pid" +paused_daemon_pid="$draining_daemon_pid" +kill -INT "$hold_pid" +for _ in $(seq 1 40); do + if [ -e "$registry" ] && [ "$(read_json_field "$registry" lifecycle)" = "draining" ]; then break fi - if ! kill -0 "$busy_pid" 2>/dev/null; then - echo "expected temporary busy-port server to stay alive" >&2 - exit 1 - fi sleep 0.05 done -if [ ! -s "$busy_port_file" ]; then - echo "timed out waiting for temporary busy-port server" >&2 +if [ ! -e "$registry" ] || [ "$(read_json_field "$registry" lifecycle)" != "draining" ]; then + echo "expected an interrupted owner to retain a draining generation fence" >&2 + cat "$registry" >&2 exit 1 fi -busy_port="$(cat "$busy_port_file")" - -( - cd "$tmp3" - busy_out="$(mktemp /tmp/beam-wrapper-busy-port-out-XXXXXX)" - busy_err="$(mktemp /tmp/beam-wrapper-busy-port-err-XXXXXX)" - if "$beam_script" --port "$busy_port" ensure lean >"$busy_out" 2>"$busy_err"; then - echo "expected wrapper ensure to reject a port already used by a non-Beam process" >&2 - cat "$busy_out" >&2 - cat "$busy_err" >&2 - rm -f "$busy_out" "$busy_err" - exit 1 - fi - if ! grep -q 'already in use' "$busy_err"; then - echo "expected non-Beam port collision failure to report the occupied endpoint" >&2 - cat "$busy_out" >&2 - cat "$busy_err" >&2 - rm -f "$busy_out" "$busy_err" - exit 1 - fi - if [ -f "$tmp3/.beam/beam-daemon.json" ]; then - echo "expected non-Beam port collision failure not to write a registry" >&2 - cat "$tmp3/.beam/beam-daemon.json" >&2 - rm -f "$busy_out" "$busy_err" +if ! kill -0 "$hold_pid" 2>/dev/null || ! kill -0 "$draining_daemon_pid" 2>/dev/null; then + echo "expected the paused daemon and its owner to remain alive during the drain check" >&2 + exit 1 +fi +draining_lookup_out="$tmp1/draining-lookup.out" +draining_lookup_err="$tmp1/draining-lookup.err" +if "$beam_script" --root "$tmp1" ensure > "$draining_lookup_out" 2> "$draining_lookup_err"; then + echo "expected an ordinary command not to attach to a draining generation" >&2 + cat "$draining_lookup_out" >&2 + exit 1 +fi +if ! grep -Fq "is draining" "$draining_lookup_err"; then + echo "expected ordinary commands to report the draining generation" >&2 + cat "$draining_lookup_err" >&2 + exit 1 +fi +replacement_owner_out="$tmp1/replacement-during-drain.out" +replacement_owner_err="$tmp1/replacement-during-drain.err" +if "$beam_script" --root "$tmp1" ensure --hold \ + > "$replacement_owner_out" 2> "$replacement_owner_err"; then + echo "expected a draining generation to fence out a replacement owner" >&2 + cat "$replacement_owner_out" >&2 + exit 1 +fi +if ! grep -Fq "is draining" "$replacement_owner_err"; then + echo "expected replacement-owner rejection to identify the draining generation" >&2 + cat "$replacement_owner_err" >&2 + exit 1 +fi +if [ "$(read_json_field "$registry" daemonId)" != "$draining_daemon_id" ] || \ + [ "$(read_json_field "$registry" pid)" != "$draining_daemon_pid" ]; then + echo "replacement attempt changed the draining generation fence" >&2 + cat "$registry" >&2 + exit 1 +fi +if ! wait_for_exit "$hold_pid" "owner after forced draining-fence cleanup" 300 0.05; then + cat "$tmp1/owner-draining-fence.err" >&2 + exit 1 +fi +wait "$hold_pid" +hold_pid="" +paused_daemon_pid="" +if ! wait_for_exit "$draining_daemon_pid" "daemon after forced draining-fence cleanup" 40 0.05; then + exit 1 +fi +for backend_pid in $draining_backend_pids; do + if ! wait_for_exit "$backend_pid" "backend after forced draining-fence cleanup" 40 0.05; then + echo "forced owner cleanup left backend pid $backend_pid alive" >&2 exit 1 fi - rm -f "$busy_out" "$busy_err" -) -kill "$busy_pid" > /dev/null 2>&1 || true -wait "$busy_pid" 2>/dev/null || true -busy_pid="" -rm -f "$busy_port_file" +done +set +e +wait "$active_request_pid" +draining_request_status="$?" +set -e +active_request_pid="" +if [ "$draining_request_status" -eq 0 ]; then + echo "expected the active request to fail when forced drain kills its process group" >&2 + exit 1 +fi +if [ -e "$registry" ]; then + echo "expected the draining fence to disappear only after the daemon was reaped" >&2 + cat "$registry" >&2 + exit 1 +fi -# Git removes the project-local registry together with a worktree. The daemon must observe the -# missing canonical root and stop itself, because no later wrapper invocation can discover it. -removed_root_pid="$pid1" -remove_owned_tmp_tree "$tmp1" -if ! wait_for_exit "$removed_root_pid" "daemon whose worktree was removed" 100 0.1; then - echo "expected Beam daemon $removed_root_pid to exit after its project root disappeared" >&2 +start_owner "$tmp1" "owner-3" +daemon3_pid="$(read_json_field "$registry" pid)" +start_slow_request "$tmp1" "owner-loss-active" "owner-loss-active" +kill -KILL "$hold_pid" +set +e +wait "$hold_pid" +set -e +hold_pid="" +if ! wait_for_exit "$daemon3_pid" "daemon after owner death" 200 0.05; then + echo "expected owner-pipe EOF to stop the daemon" >&2 + exit 1 +fi +expect_slow_request_cancelled "$tmp1" "owner-loss-active" "owner-loss-active" +owner_loss_out="$tmp1/owner-loss.out" +owner_loss_err="$tmp1/owner-loss.err" +if "$beam_script" --root "$tmp1" ensure > "$owner_loss_out" 2> "$owner_loss_err"; then + echo "expected a command after owner loss to preserve the abnormal-session fence" >&2 + cat "$owner_loss_out" >&2 + exit 1 +fi +owner_loss_generation="$(read_json_field "$registry" daemonId)" +if ! grep -Fq "recover --generation $owner_loss_generation" "$owner_loss_err"; then + echo "expected owner-loss diagnostics to name exact-generation recovery" >&2 + cat "$owner_loss_err" >&2 + exit 1 +fi +if [ ! -e "$registry" ]; then + echo "ordinary owner-loss lookup must not mutate the stale registry" >&2 + exit 1 +fi +if "$beam_script" --root "$tmp1" recover --generation wrong-generation \ + > "$tmp1/recover-wrong.out" 2> "$tmp1/recover-wrong.err"; then + echo "expected recovery with the wrong generation to fail closed" >&2 + exit 1 +fi +if [ ! -e "$registry" ]; then + echo "wrong-generation recovery must preserve the session fence" >&2 + exit 1 +fi +recover_json="$("$beam_script" --root "$tmp1" recover --generation "$owner_loss_generation")" +assert_json_field_equals "exact-generation recovery" "$recover_json" recovered true +if [ -e "$registry" ]; then + echo "expected explicit recovery to quarantine the stale session descriptor" >&2 + exit 1 +fi +quarantined_registry="$(json_text_field "$recover_json" quarantinedPath)" +if [ ! -f "$quarantined_registry" ]; then + echo "expected explicit recovery to preserve quarantined evidence" >&2 + printf '%s\n' "$recover_json" >&2 + exit 1 +fi + +explicit_control="$tmp2/shared-control" +nonprivate_control="$tmp2/nonprivate-control" +mkdir -p "$nonprivate_control" +chmod 755 "$nonprivate_control" +nonprivate_mode_before="$(file_mode "$nonprivate_control")" +if "$beam_script" --root "$tmp2" --control-dir "$nonprivate_control" recover --force \ + > "$tmp2/nonprivate-control.out" 2> "$tmp2/nonprivate-control.err"; then + echo "expected an existing non-private control directory to be rejected" >&2 + exit 1 +fi +if ! grep -Fq "existing mode is 0755, expected 0700" "$tmp2/nonprivate-control.err"; then + echo "expected non-private control rejection to explain the required mode" >&2 + cat "$tmp2/nonprivate-control.err" >&2 + exit 1 +fi +if [ "$(file_mode "$nonprivate_control")" != "$nonprivate_mode_before" ]; then + echo "non-private control rejection changed the existing directory mode" >&2 + exit 1 +fi +if find "$nonprivate_control" -mindepth 1 -print -quit | grep -q .; then + echo "non-private control rejection created files in the existing directory" >&2 + find "$nonprivate_control" -mindepth 1 -maxdepth 2 -print >&2 + exit 1 +fi + +# An absent exact control leaf is safe to create and privatize before descriptor publication. +"$beam_script" --root "$tmp2" --control-dir "$explicit_control" ensure --hold \ + > "$tmp2/explicit-control-owner.out" 2> "$tmp2/explicit-control-owner.err" & +hold_pid="$!" +explicit_registry="$explicit_control/beam-daemon.json" +if ! wait_for_nonempty_file "$explicit_registry" "explicit control-directory session descriptor"; then + cat "$tmp2/explicit-control-owner.err" >&2 + exit 1 +fi +explicit_stats="$("$beam_script" --root "$tmp2" --control-dir "$explicit_control" stats)" +assert_json_field_equals "explicit control-directory attachment" "$explicit_stats" ok true +if [ "$(file_mode "$explicit_control")" != "700" ] || \ + [ "$(file_mode "$explicit_registry")" != "600" ]; then + echo "expected a newly created explicit control directory and descriptor to use modes 700/600" >&2 + exit 1 +fi +if "$beam_script" --root "$tmp2" stats \ + > "$tmp2/default-control-stats.out" 2> "$tmp2/default-control-stats.err"; then + echo "expected the default and explicit control selections to remain distinct" >&2 + exit 1 +fi +if [ -e "$tmp2/.beam/beam-daemon.json" ]; then + echo "explicit control-directory ownership must not publish a project-local descriptor" >&2 + exit 1 +fi +if [ ! -f "$explicit_control/beam-daemon-startup.log" ]; then + echo "expected explicit control-directory startup diagnostics beside the descriptor" >&2 + exit 1 +fi +stop_hold_process true +if [ -e "$explicit_registry" ]; then + echo "expected normal explicit-control teardown to remove its descriptor" >&2 exit 1 fi -removed_root_pid="" -removed_root_err="$(mktemp /tmp/beam-wrapper-removed-root-XXXXXX)" -if "$beam_script" --root "$tmp1" ensure lean > /dev/null 2>"$removed_root_err"; then - echo "expected a wrapper request for the removed worktree to fail" >&2 +generation_registry="$tmp2/.beam/beam-daemon.json" +start_owner "$tmp2" "owner-generation" +generation_daemon_pid="$(read_json_field "$generation_registry" pid)" +generation_id="$(read_json_field "$generation_registry" daemonId)" +replacement_generation_id="$generation_id-replacement" +replacement_generation_capability="replacement-generation-capability" +python3 - "$generation_registry" "$replacement_generation_id" \ + "$replacement_generation_capability" <<'PY' +import json +import os +import sys + +path, replacement_id, replacement_capability = sys.argv[1:] +with open(path, "r", encoding="utf-8") as stream: + entry = json.load(stream) +entry["daemonId"] = replacement_id +entry["capability"] = replacement_capability +replacement = path + ".replacement" +with open(replacement, "w", encoding="utf-8") as stream: + json.dump(entry, stream, separators=(",", ":")) + stream.write("\n") +os.replace(replacement, path) +PY +if ! wait_for_exit "$hold_pid" "owner after generation replacement" 200 0.05; then + cat "$tmp2/owner-generation.err" >&2 + exit 1 +fi +wait "$hold_pid" +hold_pid="" +if ! wait_for_exit "$generation_daemon_pid" "daemon after generation replacement" 200 0.05; then + exit 1 +fi +if [ "$(read_json_field "$generation_registry" daemonId)" != "$replacement_generation_id" ]; then + echo "expected old-owner cleanup to preserve the replacement registry generation" >&2 + cat "$generation_registry" >&2 + exit 1 +fi +if [ "$(read_json_field "$generation_registry" capability)" != \ + "$replacement_generation_capability" ]; then + echo "expected old-owner cleanup to preserve the replacement capability" >&2 + cat "$generation_registry" >&2 + exit 1 +fi +rm -f -- "$generation_registry" + +start_owner "$tmp1" "owner-4" +daemon4_pid="$(read_json_field "$registry" pid)" +remove_owned_tmp_tree "$tmp1" +root_removed="true" +if ! wait_for_exit "$daemon4_pid" "daemon whose project root disappeared" 200 0.05; then + echo "expected root disappearance to stop the owned daemon" >&2 + exit 1 +fi +if ! wait_for_exit "$hold_pid" "owner whose project root disappeared" 200 0.05; then + echo "expected root disappearance to release the foreground owner" >&2 + exit 1 +fi +set +e +wait "$hold_pid" +root_owner_status="$?" +set -e +hold_pid="" +if [ "$root_owner_status" -ne 0 ]; then + echo "expected root-disappearance owner to exit cleanly, got $root_owner_status" >&2 exit 1 fi -if ! grep -Fq 'workspace root does not resolve' "$removed_root_err"; then - echo "expected a removed-worktree request to report that its workspace root no longer resolves" >&2 - cat "$removed_root_err" >&2 +if [ -e "$tmp1" ]; then + echo "owner cleanup recreated the removed project root" >&2 exit 1 fi -rm -f "$removed_root_err" -removed_root_err="" diff --git a/tests/test-beam-wrapper-diagnostics.sh b/tests/test-beam-wrapper-diagnostics.sh index 5c653ae4..1903c241 100755 --- a/tests/test-beam-wrapper-diagnostics.sh +++ b/tests/test-beam-wrapper-diagnostics.sh @@ -19,6 +19,13 @@ warn_full_root="$(beam_wrapper_prepare_project_root diagnostics-warn-full)" stale_root="$(beam_wrapper_prepare_project_root diagnostics-stale)" renamed_stale_root="$(beam_wrapper_prepare_project_root diagnostics-renamed-stale)" +beam_wrapper_start_owner "$broken_root" +beam_wrapper_start_owner "$guard_msgs_io_stderr_root" +beam_wrapper_start_owner "$warn_root" +beam_wrapper_start_owner "$warn_full_root" +beam_wrapper_start_owner "$stale_root" +beam_wrapper_start_owner "$renamed_stale_root" + fail_json() { local message="$1" local json_payload="$2" @@ -98,6 +105,7 @@ expect_sync_result_shape() { expect_json_field_present "$json_payload" "$prefix.readiness" "$label readiness" "$err_file" } +echo "[beam-wrapper:diagnostics] starting blocking-error diagnostics" ( cd "$broken_root" @@ -182,7 +190,9 @@ expect_sync_result_shape() { exit 1 fi ) +echo "[beam-wrapper:diagnostics] passed: blocking-error diagnostics" +echo "[beam-wrapper:diagnostics] starting guard_msgs stderr handling" ( cd "$guard_msgs_io_stderr_root" @@ -225,7 +235,9 @@ EOF exit 1 fi ) +echo "[beam-wrapper:diagnostics] passed: guard_msgs stderr handling" +echo "[beam-wrapper:diagnostics] starting default warning filtering" ( cd "$warn_root" "$beam_script" ensure lean > /dev/null @@ -286,7 +298,9 @@ EOF exit 1 fi ) +echo "[beam-wrapper:diagnostics] passed: default warning filtering" +echo "[beam-wrapper:diagnostics] starting full warning streaming" ( cd "$warn_full_root" "$beam_script" ensure lean > /dev/null @@ -337,12 +351,13 @@ EOF warn_full_registry="$(beam_wrapper_registry_path "$warn_full_root")" beam_wrapper_expect_file "$warn_full_registry" port9="$(read_json_field "$warn_full_registry" port)" + capability9="$(read_json_field "$warn_full_registry" capability)" client9="$(read_json_field "$warn_full_registry" clientBin 2>/dev/null || true)" if [ -z "$client9" ]; then client9="$client" fi - stream_req="$(printf '{"op":"sync_file","workspaceId":"beam-cli-project","root":"%s","path":"SaveSmoke/B.lean","diagnosticScope":"all"}' "$warn_full_root")" + stream_req="$(printf '{"op":"sync_file","workspaceId":"beam-cli-project","root":"%s","path":"SaveSmoke/B.lean","diagnosticScope":"all","daemonCapability":"%s"}' "$warn_full_root" "$capability9")" stream_out="$(beam_wrapper_mktemp_file stream-out)" stream_err="$(beam_wrapper_mktemp_file stream-err)" "$client9" --port "$port9" request-stream "$stream_req" >"$stream_out" 2>"$stream_err" @@ -431,8 +446,10 @@ EOF exit 1 fi ) +echo "[beam-wrapper:diagnostics] passed: full warning streaming" +echo "[beam-wrapper:diagnostics] starting renamed-dependency stale recovery" ( cd "$renamed_stale_root" @@ -630,7 +647,9 @@ EOF exit 1 fi ) +echo "[beam-wrapper:diagnostics] passed: renamed-dependency stale recovery" +echo "[beam-wrapper:diagnostics] starting stale-import recovery" ( cd "$stale_root" lake build SaveSmoke/A.lean > /dev/null @@ -758,3 +777,4 @@ EOF fi assert_json_completed_file_progress "recovered lean-refresh" "$refreshed_a" fileProgress ) +echo "[beam-wrapper:diagnostics] passed: stale-import recovery" diff --git a/tests/test-beam-wrapper-handle.sh b/tests/test-beam-wrapper-handle.sh index 2bfbefae..2d75c87f 100644 --- a/tests/test-beam-wrapper-handle.sh +++ b/tests/test-beam-wrapper-handle.sh @@ -13,6 +13,7 @@ cd "$(dirname "$0")/.." beam_wrapper_init handle_root="$(beam_wrapper_prepare_project_root handle)" +beam_wrapper_start_owner "$handle_root" ( cd "$handle_root" diff --git a/tests/test-beam-wrapper-probe.sh b/tests/test-beam-wrapper-probe.sh index 338e0e61..0c92a461 100644 --- a/tests/test-beam-wrapper-probe.sh +++ b/tests/test-beam-wrapper-probe.sh @@ -13,6 +13,7 @@ cd "$(dirname "$0")/.." beam_wrapper_init project_root="$(beam_wrapper_prepare_project_root probe)" +beam_wrapper_start_owner "$project_root" ( cd "$project_root" @@ -24,7 +25,7 @@ beam_wrapper_expect_file "$registry_path" pid1="$(read_json_field "$registry_path" pid)" port1="$(read_json_field "$registry_path" port)" -root1="$(read_json_field "$registry_path" root)" +root1="$(read_json_field "$registry_path" workspaces.0.root)" client1="$(read_json_field "$registry_path" clientBin 2>/dev/null || true)" if [ -z "$client1" ]; then client1="$client" diff --git a/tests/test-beam-wrapper-rocq.sh b/tests/test-beam-wrapper-rocq.sh index aad48668..291185cc 100755 --- a/tests/test-beam-wrapper-rocq.sh +++ b/tests/test-beam-wrapper-rocq.sh @@ -9,6 +9,8 @@ set -euo pipefail cd "$(dirname "$0")/.." # shellcheck source=tests/lib/tmp-guards.sh . tests/lib/tmp-guards.sh +# shellcheck source=tests/lib/wait.sh +. tests/lib/wait.sh beam_script="$PWD/scripts/lean-beam" rocq_cmd="${BEAM_ROCQ_CMD:-}" @@ -69,10 +71,17 @@ rsync -a \ echo "expected doctor rocq to remain read-only and not build Beam daemon helpers" >&2 exit 1 fi + rocq_owner_err="$tmp_repo/rocq-owner.err" if [ -n "$rocq_cmd" ]; then - BEAM_ROCQ_CMD="$rocq_cmd" "$tmp_repo/scripts/lean-beam" --root "$tmp_repo/tests/rocq/Minimal" ensure rocq > /dev/null + BEAM_ROCQ_CMD="$rocq_cmd" "$tmp_repo/scripts/lean-beam" --root "$tmp_repo/tests/rocq/Minimal" ensure rocq --hold \ + > /dev/null 2>"$rocq_owner_err" & else - "$tmp_repo/scripts/lean-beam" --root "$tmp_repo/tests/rocq/Minimal" ensure rocq > /dev/null + "$tmp_repo/scripts/lean-beam" --root "$tmp_repo/tests/rocq/Minimal" ensure rocq --hold \ + > /dev/null 2>"$rocq_owner_err" & + fi + rocq_owner_pid="$!" + if ! wait_for_file_text "$rocq_owner_err" "owning Beam session" "Rocq session owner" 600 0.1; then + exit 1 fi if [ ! -x ".lake/build/bin/beam-daemon" ] || [ ! -x ".lake/build/bin/beam-client" ]; then echo "expected rocq CLI startup to build missing Beam daemon helpers on demand" >&2 @@ -83,4 +92,24 @@ rsync -a \ else "$tmp_repo/scripts/lean-beam" --root "$tmp_repo/tests/rocq/Minimal" shutdown > /dev/null fi + wait_for_exit "$rocq_owner_pid" "Rocq session owner" 120 0.1 + wait "$rocq_owner_pid" + rocq_missing_out="$tmp_repo/rocq-missing-owner.out" + rocq_missing_err="$tmp_repo/rocq-missing-owner.err" + if [ -n "$rocq_cmd" ]; then + if BEAM_ROCQ_CMD="$rocq_cmd" "$tmp_repo/scripts/lean-beam" --root "$tmp_repo/tests/rocq/Minimal" ensure rocq \ + >"$rocq_missing_out" 2>"$rocq_missing_err"; then + echo "expected an ordinary Rocq command to require a session owner" >&2 + exit 1 + fi + elif "$tmp_repo/scripts/lean-beam" --root "$tmp_repo/tests/rocq/Minimal" ensure rocq \ + >"$rocq_missing_out" 2>"$rocq_missing_err"; then + echo "expected an ordinary Rocq command to require a session owner" >&2 + exit 1 + fi + if ! grep -Fq "lean-beam ensure rocq --hold" "$rocq_missing_err"; then + echo "expected Rocq missing-owner recovery to name the Rocq ownership command" >&2 + cat "$rocq_missing_err" >&2 + exit 1 + fi ) diff --git a/tests/test-beam-wrapper-runtime.sh b/tests/test-beam-wrapper-runtime.sh index 332bfef0..e523d319 100644 --- a/tests/test-beam-wrapper-runtime.sh +++ b/tests/test-beam-wrapper-runtime.sh @@ -15,7 +15,6 @@ beam_wrapper_init primary_root="$(beam_wrapper_prepare_project_root runtime-primary)" other_root="$(beam_wrapper_prepare_project_root runtime-other)" signal_root="$(beam_wrapper_prepare_project_root_with_scenario_docs runtime-signal)" -busy_port_root="$(beam_wrapper_prepare_project_root runtime-busy-port)" run_sigint_probe() { local project_root="$1" @@ -139,6 +138,8 @@ expect_sigint_cancelled() { fi } +beam_wrapper_start_owner "$primary_root" +primary_owner_pid="$beam_wrapper_last_owner_pid" ( cd "$primary_root" "$beam_script" ensure lean > /dev/null @@ -153,10 +154,10 @@ if [ -z "$client1" ]; then client1="$client" fi +beam_wrapper_start_owner "$signal_root" +signal_owner_pid="$beam_wrapper_last_owner_pid" ( cd "$signal_root" - "$beam_script" --root "$signal_root" shutdown > /dev/null 2>&1 || true - "$beam_script" --root "$signal_root" ensure lean > /dev/null slow_version="$(beam_wrapper_update_version "signal SlowPoll" "$beam_script" --root "$signal_root" lean-update tests/scenario/docs/SlowPoll.lean)" command_version="$(beam_wrapper_update_version "signal CommandA" "$beam_script" --root "$signal_root" lean-update tests/scenario/docs/CommandA.lean)" @@ -363,11 +364,14 @@ PY "$beam_script" --root "$signal_root" shutdown > /dev/null 2>&1 || true ) +wait_for_exit "$signal_owner_pid" "first signal-test session owner" 120 0.1 +wait "$signal_owner_pid" +beam_wrapper_unregister_pid "$signal_owner_pid" +beam_wrapper_start_owner "$signal_root" +signal_owner_pid="$beam_wrapper_last_owner_pid" ( cd "$signal_root" - "$beam_script" --root "$signal_root" shutdown > /dev/null 2>&1 || true - "$beam_script" --root "$signal_root" ensure lean > /dev/null slow_version="$(beam_wrapper_update_version "duplicate SlowPoll" "$beam_script" --root "$signal_root" lean-update tests/scenario/docs/SlowPoll.lean)" command_version="$(beam_wrapper_update_version "duplicate CommandA" "$beam_script" --root "$signal_root" lean-update tests/scenario/docs/CommandA.lean)" @@ -454,7 +458,11 @@ PY "$beam_script" --root "$signal_root" shutdown > /dev/null 2>&1 || true ) +wait_for_exit "$signal_owner_pid" "second signal-test session owner" 120 0.1 +wait "$signal_owner_pid" +beam_wrapper_unregister_pid "$signal_owner_pid" +beam_wrapper_start_owner "$other_root" ( cd "$other_root" "$beam_script" ensure lean > /dev/null @@ -487,71 +495,16 @@ if ! grep -q "invalidParams" "$cross_err"; then exit 1 fi -( - cd "$busy_port_root" - "$beam_script" ensure lean > /dev/null - warm_version="$(beam_wrapper_update_version "busy-port SaveSmoke/B.lean" "$beam_script" lean-update SaveSmoke/B.lean)" - warm_out="$("$beam_script" lean-run-at SaveSmoke/B.lean "$warm_version" 0 2 "#eval bVal")" - if [ "$(BEAM_JSON_PAYLOAD="$warm_out" read_json_text_field ok)" != "true" ]; then - echo "expected busy-port warmup probe to succeed before reuse check" >&2 - printf '%s\n' "$warm_out" >&2 - exit 1 - fi -) - -busy_registry="$(beam_wrapper_registry_path "$busy_port_root")" -beam_wrapper_expect_file "$busy_registry" -pid5="$(read_json_field "$busy_registry" pid)" -port5="$(read_json_field "$busy_registry" port)" -busy_port=43123 -if [ "$busy_port" = "$port5" ]; then - busy_port=43124 -fi - -python3 -m http.server "$busy_port" >/dev/null 2>&1 & -busy_pid=$! -beam_wrapper_register_pid "$busy_pid" -sleep 1 - -( - cd "$busy_port_root" - doctor_out="$("$beam_script" doctor lean)" - if ! printf '%s\n' "$doctor_out" | grep -q 'daemon status: live'; then - echo "expected doctor lean to report a live Beam daemon before requested-port reuse check" >&2 - printf '%s\n' "$doctor_out" >&2 - exit 1 - fi - sed_in_place_portable 's/1/2/' SaveSmoke/B.lean - sync_out="$("$beam_script" --port "$busy_port" lean-sync SaveSmoke/B.lean)" - if [ "$(BEAM_JSON_PAYLOAD="$sync_out" read_json_text_field ok)" != "true" ]; then - echo "expected lean-sync with a busy requested port to reuse the live Beam daemon" >&2 - printf '%s\n' "$sync_out" >&2 - exit 1 - fi - if [ "$(BEAM_JSON_PAYLOAD="$sync_out" read_json_text_field result.version)" != "2" ]; then - echo "expected busy-port lean-sync reuse path to report version 2" >&2 - printf '%s\n' "$sync_out" >&2 - exit 1 - fi - stats_out="$("$beam_script" stats)" - if [ "$(BEAM_JSON_PAYLOAD="$stats_out" read_json_text_field ok)" != "true" ]; then - echo "expected stats to keep working after busy-port lean-sync reuse" >&2 - printf '%s\n' "$stats_out" >&2 - exit 1 - fi -) - -kill "$busy_pid" > /dev/null 2>&1 || true -wait "$busy_pid" 2>/dev/null || true - -pid5_after="$(read_json_field "$busy_registry" pid)" -port5_after="$(read_json_field "$busy_registry" port)" -if [ "$pid5" != "$pid5_after" ] || [ "$port5" != "$port5_after" ]; then - echo "expected requested-port lean-sync reuse to preserve the original registry entry" >&2 +port_scope_out="$(beam_wrapper_mktemp_file port-scope-out)" +port_scope_err="$(beam_wrapper_mktemp_file port-scope-err)" +if "$beam_script" --root "$primary_root" --port 43123 stats >"$port_scope_out" 2>"$port_scope_err"; then + echo "expected --port on an attaching command to be rejected" >&2 + cat "$port_scope_out" >&2 exit 1 fi -if ! kill -0 "$pid5" 2>/dev/null; then - echo "expected original Beam daemon pid $pid5 to remain alive after busy-port lean-sync reuse" >&2 +if ! grep -Fq -- "--port is only valid" "$port_scope_err"; then + echo "expected rejected attaching --port to explain the owner-only scope" >&2 + cat "$port_scope_err" >&2 exit 1 fi @@ -559,6 +512,9 @@ fi cd "$primary_root" "$beam_script" shutdown > /dev/null ) +wait_for_exit "$primary_owner_pid" "primary session owner" 120 0.1 +wait "$primary_owner_pid" +beam_wrapper_unregister_pid "$primary_owner_pid" if [ -f "$primary_registry" ]; then echo "expected shutdown to remove the project Beam daemon registry" >&2 diff --git a/tests/test-beam-wrapper-sandbox.sh b/tests/test-beam-wrapper-sandbox.sh index 8a475ae1..0a54b4ea 100755 --- a/tests/test-beam-wrapper-sandbox.sh +++ b/tests/test-beam-wrapper-sandbox.sh @@ -40,37 +40,33 @@ if ! bwrap --new-session --die-with-parent \ exit 0 fi -tmp_root="$(mktemp -d /tmp/beam-wrapper-sandbox-XXXXXX)" +tmp_root="$(mktemp -d /tmp/beam-wrapper-sandbox-owner-XXXXXX)" project_root="$tmp_root/project" control_root="$tmp_root/control" -hold_out="$tmp_root/hold.out" -hold_err="$tmp_root/hold.err" -owner_out="$tmp_root/owner.out" -owner_err="$tmp_root/owner.err" -follower_out="$tmp_root/follower.out" -follower_err="$tmp_root/follower.err" +owner_pid="" +owner_stop="" +owner_kill="" +owner_resume="" cleanup() { - if [ -n "${hold_pid:-}" ]; then - kill "$hold_pid" > /dev/null 2>&1 || true - wait "$hold_pid" 2>/dev/null || true - fi - if [ -n "${owner_pid:-}" ]; then - kill "$owner_pid" > /dev/null 2>&1 || true + if [ -n "$owner_pid" ]; then + if [ -n "$owner_resume" ]; then + touch "$owner_resume" + fi + if [ -n "$owner_stop" ]; then + touch "$owner_stop" + fi + if ! wait_for_exit "$owner_pid" "sandbox owner cleanup" 100 0.05; then + kill "$owner_pid" >/dev/null 2>&1 || true + fi wait "$owner_pid" 2>/dev/null || true fi - if [ -n "${follower_pid:-}" ]; then - sandbox_beam cancel wrapper-sandbox-follower > /dev/null 2>&1 || true - wait "$follower_pid" 2>/dev/null || true - fi remove_owned_tmp_tree "$tmp_root" } trap cleanup EXIT mkdir -p "$project_root" "$control_root" rsync -a --exclude='.beam/' tests/save_olean_project/ "$project_root"/ -mkdir -p "$project_root/tests/scenario/docs" -cp tests/scenario/docs/SlowPoll.lean "$project_root/tests/scenario/docs/SlowPoll.lean" sandbox_beam() { bwrap --new-session --die-with-parent \ @@ -80,25 +76,16 @@ sandbox_beam() { --proc /proc \ --unshare-pid \ --chdir "$project_root" \ - -- /usr/bin/env BEAM_CONTROL_DIR="$control_root" "$beam_script" --root "$project_root" "$@" -} - -sandbox_shell_hold() { - local hold_secs="$1" - bwrap --new-session --die-with-parent \ - --ro-bind / / \ - --dev-bind /dev /dev \ - --bind /tmp /tmp \ - --proc /proc \ - --unshare-pid \ - --chdir "$project_root" \ - -- /bin/bash -lc "export BEAM_CONTROL_DIR='$control_root'; '$beam_script' --root '$project_root' ensure lean >'$hold_out' 2>'$hold_err'; sleep $hold_secs" + -- /usr/bin/env BEAM_CONTROL_ROOT="$control_root" \ + "$beam_script" --root "$project_root" "$@" } wait_for_registry() { local remaining=300 while [ "$remaining" -gt 0 ]; do - registry="$(find "$control_root" -name beam-daemon.json -print | sed -n '1p')" + # Registry publication is concurrent with this traversal. Ignore transient observational + # misses while the per-root control directory is being created. + registry="$(find "$control_root" -name beam-daemon.json -print 2>/dev/null | sed -n '1p' || true)" if [ -n "$registry" ] && [ -f "$registry" ]; then return 0 fi @@ -108,132 +95,261 @@ wait_for_registry() { return 1 } -sandbox_shell_hold 10 & -hold_pid="$!" +assert_no_daemon_failure_incidents() { + local label="$1" + if find "$control_root" -path '*/daemon-failures/*.json' -print -quit | grep -q .; then + echo "expected $label to produce no daemon failure incident" >&2 + find "$control_root" -path '*/daemon-failures/*.json' -print -exec sed -n '1,160p' {} \; >&2 + exit 1 + fi +} + +assert_no_lease_artifacts() { + local artifact + artifact="$(find "$control_root" -type f \ + \( -name '*.lease' -o -name '*.revoked' -o -name '*retir*' \) -print -quit)" + if [ -n "$artifact" ]; then + echo "explicit ownership must not publish lease or retirement artifacts: $artifact" >&2 + exit 1 + fi +} + +sandbox_owner() { + local name="$1" + local out="$tmp_root/$name.out" + local err="$tmp_root/$name.err" + local stop="$tmp_root/$name.stop" + local kill_marker="$tmp_root/$name.kill" + local pause="$tmp_root/$name.pause" + local paused="$tmp_root/$name.paused" + local resume="$tmp_root/$name.resume" + + bwrap --new-session --die-with-parent \ + --ro-bind / / \ + --dev-bind /dev /dev \ + --bind /tmp /tmp \ + --proc /proc \ + --unshare-pid \ + --chdir "$project_root" \ + -- /bin/bash -lc \ + "export BEAM_CONTROL_ROOT='$control_root'; \ + '$beam_script' --root '$project_root' ensure --hold >'$out' 2>'$err' & \ + wrapper_pid=\$!; \ + (paused=false; \ + while kill -0 \"\$wrapper_pid\" 2>/dev/null; do \ + if [ -f '$pause' ] && [ \"\$paused\" = false ]; then \ + kill -STOP \"\$wrapper_pid\"; paused=true; touch '$paused'; \ + fi; \ + if [ -f '$resume' ] && [ \"\$paused\" = true ]; then \ + kill -CONT \"\$wrapper_pid\"; paused=false; \ + fi; \ + if [ -f '$kill_marker' ]; then \ + [ \"\$paused\" = false ] || kill -CONT \"\$wrapper_pid\"; \ + kill -KILL \"\$wrapper_pid\"; exit 0; \ + fi; \ + if [ -f '$stop' ]; then \ + [ \"\$paused\" = false ] || kill -CONT \"\$wrapper_pid\"; \ + kill -INT \"\$wrapper_pid\"; exit 0; \ + fi; \ + sleep 0.05; \ + done) & \ + controller_pid=\$!; \ + set +e; wait \"\$wrapper_pid\" 2>/dev/null; status=\$?; set -e; \ + kill \"\$controller_pid\" 2>/dev/null || true; \ + wait \"\$controller_pid\" 2>/dev/null || true; \ + exit \"\$status\"" & + + owner_pid="$!" + owner_stop="$stop" + owner_kill="$kill_marker" + owner_pause="$pause" + owner_paused="$paused" + owner_resume="$resume" + owner_out="$out" + owner_err="$err" +} + +missing_out="$tmp_root/missing.out" +missing_err="$tmp_root/missing.err" +if sandbox_beam ensure >"$missing_out" 2>"$missing_err"; then + echo "expected an ordinary sandbox command not to start an implicit daemon" >&2 + sed -n '1,160p' "$missing_out" >&2 + exit 1 +fi +if ! grep -Fq "start 'lean-beam ensure --hold'" "$missing_err"; then + echo "expected the missing-owner error to provide the ownership command" >&2 + sed -n '1,160p' "$missing_err" >&2 + exit 1 +fi -if ! wait_for_registry; then - echo "expected sandboxed wrapper ensure to create a control-dir registry" >&2 - cat "$hold_err" >&2 +sandbox_owner owner-1 +if ! wait_for_registry || ! wait_for_nonempty_file "$owner_out" "sandbox owner response"; then + echo "expected ensure --hold to publish an owned daemon" >&2 + sed -n '1,200p' "$owner_err" >&2 exit 1 fi daemon_id_1="$(read_json_field "$registry" daemonId)" port_1="$(read_json_field "$registry" port)" -pid_ns_1="$(read_json_field "$registry" pidNamespace 2>/dev/null || true)" - -if [ -z "$pid_ns_1" ]; then - echo "expected sandboxed wrapper registry to record the daemon pid namespace for debugging" >&2 - cat "$registry" >&2 - exit 1 -fi doctor_out="$(sandbox_beam doctor)" if ! printf '%s\n' "$doctor_out" | grep -q 'daemon status: live'; then - echo "expected a PID-isolated wrapper invocation to reuse the live daemon via the registry endpoint" >&2 + echo "expected a separate PID namespace to observe the owned daemon endpoint" >&2 printf '%s\n' "$doctor_out" >&2 exit 1 fi -if ! printf '%s\n' "$doctor_out" | grep -q 'daemon pid namespace: '; then - echo "expected doctor output to surface the daemon pid namespace for debugging" >&2 - printf '%s\n' "$doctor_out" >&2 + +ensure_json="$(sandbox_beam ensure)" +if [ "$(json_text_field "$ensure_json" ok)" != "true" ]; then + echo "expected a separate PID namespace to attach to the owner session" >&2 + printf '%s\n' "$ensure_json" >&2 exit 1 fi - -sandbox_beam ensure lean > /dev/null - -daemon_id_2="$(read_json_field "$registry" daemonId)" -port_2="$(read_json_field "$registry" port)" -pid_ns_2="$(read_json_field "$registry" pidNamespace 2>/dev/null || true)" - -if [ "$daemon_id_1" != "$daemon_id_2" ]; then - echo "expected PID-isolated wrapper ensure to reuse the existing daemon instead of starting a new one" >&2 - printf 'before daemonId: %s\n' "$daemon_id_1" >&2 - printf 'after daemonId: %s\n' "$daemon_id_2" >&2 +if [ "$(read_json_field "$registry" daemonId)" != "$daemon_id_1" ] || \ + [ "$(read_json_field "$registry" port)" != "$port_1" ]; then + echo "ordinary commands must preserve the owner's daemon generation and endpoint" >&2 exit 1 fi -if [ "$port_1" != "$port_2" ]; then - echo "expected PID-isolated wrapper ensure to preserve the daemon endpoint" >&2 - printf 'before port: %s\n' "$port_1" >&2 - printf 'after port: %s\n' "$port_2" >&2 +duplicate_out="$tmp_root/duplicate.out" +duplicate_err="$tmp_root/duplicate.err" +if sandbox_beam ensure --hold >"$duplicate_out" 2>"$duplicate_err"; then + echo "expected a second sandbox owner to be rejected" >&2 exit 1 fi - -if [ "$pid_ns_1" != "$pid_ns_2" ]; then - echo "expected PID-isolated wrapper ensure to preserve the recorded daemon pid namespace" >&2 - printf 'before pid namespace: %s\n' "$pid_ns_1" >&2 - printf 'after pid namespace: %s\n' "$pid_ns_2" >&2 +if ! grep -Fq 'already owned' "$duplicate_err"; then + echo "expected duplicate-owner diagnostics to identify the live owner" >&2 + sed -n '1,160p' "$duplicate_err" >&2 exit 1 fi -kill "$hold_pid" > /dev/null 2>&1 || true -wait "$hold_pid" 2>/dev/null || true -hold_pid="" - -find "$control_root" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - -sandbox_beam ensure lean >"$owner_out" 2>"$owner_err" & -owner_pid="$!" -if ! wait_for_registry; then - echo "expected owner sandbox wrapper request to create a control-dir registry" >&2 - cat "$owner_err" >&2 +# Ownership is the lifetime of the holder's inherited pipe, not a periodically renewed lease. +# Pausing the holder therefore leaves the session usable by clients in other PID namespaces. +touch "$owner_pause" +if ! wait_for_file "$owner_paused" "paused sandbox owner" 12; then + sed -n '1,160p' "$owner_err" >&2 exit 1 fi -follower_version="$(beam_wrapper_update_version "sandbox SlowPoll" sandbox_beam update tests/scenario/docs/SlowPoll.lean)" -BEAM_PROGRESS=1 BEAM_REQUEST_ID=wrapper-sandbox-follower \ - sandbox_beam run-at tests/scenario/docs/SlowPoll.lean "$follower_version" 25 2 poll_sleep_cmd \ - >"$follower_out" 2>"$follower_err" & -follower_pid="$!" +sleep 3 +paused_stats="$(sandbox_beam stats)" +if [ "$(json_text_field "$paused_stats" ok)" != "true" ]; then + echo "expected the daemon to remain usable while its explicit owner is paused" >&2 + printf '%s\n' "$paused_stats" >&2 + exit 1 +fi +assert_no_lease_artifacts +touch "$owner_resume" -if ! wait_for_file_text "$follower_err" "snapshot progress" "follower sandbox wrapper request progress"; then - cat "$owner_out" >&2 - cat "$owner_err" >&2 - cat "$follower_out" >&2 - cat "$follower_err" >&2 +shutdown_json="$(sandbox_beam shutdown)" +if [ "$(json_text_field "$shutdown_json" result.shutdown)" != "true" ]; then + echo "expected explicit shutdown to close the owned sandbox session" >&2 + printf '%s\n' "$shutdown_json" >&2 + exit 1 +fi +if ! wait_for_exit "$owner_pid" "sandbox owner after shutdown" 120 0.1; then + sed -n '1,200p' "$owner_err" >&2 exit 1 fi -if ! wait_for_nonempty_file "$owner_out" "owner sandbox ensure response"; then - cat "$owner_err" >&2 - cat "$follower_out" >&2 - cat "$follower_err" >&2 +if ! wait "$owner_pid"; then + echo "expected explicit shutdown to release the sandbox owner cleanly" >&2 + sed -n '1,200p' "$owner_err" >&2 + exit 1 +fi +owner_pid="" +if find "$control_root" -name beam-daemon.json -print -quit | grep -q .; then + echo "expected explicit shutdown to remove the owned generation registry" >&2 exit 1 fi -if ! kill -0 "$follower_pid" 2>/dev/null; then - echo "expected the follower sandbox request to stay alive while the owner request finishes" >&2 - cat "$owner_out" >&2 - cat "$owner_err" >&2 - cat "$follower_out" >&2 - cat "$follower_err" >&2 +sandbox_owner owner-2 +if ! wait_for_registry || ! wait_for_nonempty_file "$owner_out" "replacement sandbox owner response"; then + sed -n '1,200p' "$owner_err" >&2 + exit 1 +fi +daemon_id_2="$(read_json_field "$registry" daemonId)" +if [ "$daemon_id_1" = "$daemon_id_2" ]; then + echo "expected a replacement owner to publish a new daemon generation" >&2 exit 1 fi -cancel_json="$(sandbox_beam cancel wrapper-sandbox-follower)" -if ! printf '%s\n' "$cancel_json" | python3 -c 'import json,sys; payload=json.load(sys.stdin); raise SystemExit(0 if payload.get("result", {}).get("cancelled") is True else 1)'; then - echo "expected sandbox wrapper cancel request to acknowledge the follower request id" >&2 - printf '%s\n' "$cancel_json" >&2 + +# Killing the holder closes the only write end of the inherited owner pipe. The daemon must stop +# without a heartbeat timeout. A later command in another PID namespace cannot prove that the +# foreign-domain process identities are gone, so it must preserve the descriptor for explicit +# recovery rather than silently treating endpoint unavailability as replacement authority. +touch "$owner_kill" +if ! wait_for_exit "$owner_pid" "killed sandbox owner" 120 0.1; then + sed -n '1,200p' "$owner_err" >&2 exit 1 fi set +e -wait "$follower_pid" -follower_status=$? +wait "$owner_pid" +owner_status="$?" set -e -follower_pid="" +owner_pid="" +if [ "$owner_status" -eq 0 ]; then + echo "expected the deliberately killed owner wrapper to exit non-zero" >&2 + exit 1 +fi +sleep 4 -if [ "$follower_status" = "0" ]; then - echo "expected follower sandbox wrapper request to exit non-zero after cancellation" >&2 - cat "$follower_out" >&2 - cat "$follower_err" >&2 +after_kill_out="$tmp_root/after-kill.out" +after_kill_err="$tmp_root/after-kill.err" +if sandbox_beam ensure >"$after_kill_out" 2>"$after_kill_err"; then + echo "expected an ordinary command not to replace a dead owner implicitly" >&2 + sed -n '1,160p' "$after_kill_out" >&2 + exit 1 +fi +if ! grep -Fq "recorded daemon endpoint is unavailable" "$after_kill_err"; then + echo "expected cross-domain owner loss to fail closed on the unavailable endpoint" >&2 + sed -n '1,160p' "$after_kill_err" >&2 + exit 1 +fi +if ! find "$control_root" -name beam-daemon.json -print -quit | grep -q .; then + echo "expected ordinary cross-domain lookup to preserve the unsafe descriptor" >&2 exit 1 fi -follower_json="$(cat "$follower_out")" -if ! python3 -c 'import json,sys; payload=json.load(sys.stdin); raise SystemExit(0 if payload.get("error", {}).get("code") == "requestCancelled" else 1)' <<<"$follower_json" -then - echo "expected follower sandbox wrapper request to report requestCancelled after SIGINT" >&2 - printf '%s\n' "$follower_json" >&2 - cat "$follower_err" >&2 +# This test harness supervised the complete bwrap owner namespace and observed its exit, so it can +# now authorize exact-generation recovery that an ordinary client must refuse to infer. +recovery_json="$(sandbox_beam recover --generation "$daemon_id_2")" +if [ "$(json_text_field "$recovery_json" recovered)" != "true" ]; then + echo "expected exact-generation sandbox recovery to quarantine the descriptor" >&2 + printf '%s\n' "$recovery_json" >&2 + exit 1 +fi +if [ -e "$registry" ]; then + echo "expected exact-generation recovery to remove the authoritative fence" >&2 + exit 1 +fi +recovery_path="$(json_text_field "$recovery_json" quarantinedPath)" +if [ ! -f "$recovery_path" ]; then + echo "expected sandbox recovery to preserve quarantined evidence" >&2 + printf '%s\n' "$recovery_json" >&2 exit 1 fi -wait "$owner_pid" +sandbox_owner owner-3 +if ! wait_for_registry || ! wait_for_nonempty_file "$owner_out" "final sandbox owner response"; then + sed -n '1,200p' "$owner_err" >&2 + exit 1 +fi +final_stats="$(sandbox_beam stats)" +if [ "$(json_text_field "$final_stats" ok)" != "true" ]; then + echo "expected a new explicit owner to restore the session" >&2 + printf '%s\n' "$final_stats" >&2 + exit 1 +fi +touch "$owner_stop" +if ! wait_for_exit "$owner_pid" "interrupted sandbox owner" 120 0.1; then + sed -n '1,200p' "$owner_err" >&2 + exit 1 +fi +if ! wait "$owner_pid"; then + echo "expected SIGINT to release the final sandbox owner cleanly" >&2 + sed -n '1,200p' "$owner_err" >&2 + exit 1 +fi owner_pid="" + +assert_no_lease_artifacts +assert_no_daemon_failure_incidents "the explicit sandbox ownership regressions" diff --git a/tests/test-beam-wrapper-sync-save.sh b/tests/test-beam-wrapper-sync-save.sh index 547894d5..98cef6f6 100755 --- a/tests/test-beam-wrapper-sync-save.sh +++ b/tests/test-beam-wrapper-sync-save.sh @@ -14,6 +14,8 @@ beam_wrapper_init lifecycle_root="$(beam_wrapper_prepare_project_root sync-save)" standalone_root="$(beam_wrapper_prepare_project_root standalone-save)" +beam_wrapper_start_owner "$lifecycle_root" +beam_wrapper_start_owner "$standalone_root" ( cd "$lifecycle_root" diff --git a/tests/test-beam-wrapper.sh b/tests/test-beam-wrapper.sh index d20df962..c0520c7c 100755 --- a/tests/test-beam-wrapper.sh +++ b/tests/test-beam-wrapper.sh @@ -8,8 +8,19 @@ set -euo pipefail cd "$(dirname "$0")/.." -bash tests/test-beam-wrapper-probe.sh -bash tests/test-beam-wrapper-runtime.sh -bash tests/test-beam-wrapper-sync-save.sh -bash tests/test-beam-wrapper-handle.sh -bash tests/test-beam-wrapper-diagnostics.sh +run_wrapper_slice() { + local label="$1" + local script="$2" + echo "[beam-wrapper] starting $label" + if ! bash "$script"; then + echo "[beam-wrapper] failed: $label ($script)" >&2 + return 1 + fi + echo "[beam-wrapper] passed: $label" +} + +run_wrapper_slice "probe" tests/test-beam-wrapper-probe.sh +run_wrapper_slice "runtime" tests/test-beam-wrapper-runtime.sh +run_wrapper_slice "sync/save" tests/test-beam-wrapper-sync-save.sh +run_wrapper_slice "handles" tests/test-beam-wrapper-handle.sh +run_wrapper_slice "diagnostics" tests/test-beam-wrapper-diagnostics.sh diff --git a/tests/test-mcp-http-bridge.py b/tests/test-mcp-http-bridge.py index db62af64..29822ae6 100644 --- a/tests/test-mcp-http-bridge.py +++ b/tests/test-mcp-http-bridge.py @@ -259,7 +259,11 @@ def main(): with tempfile.TemporaryDirectory(prefix="lean-beam-mcp-http-") as tmp: tmp_path = Path(tmp) project_root = tmp_path / "project" - shutil.copytree(repo_root / "tests" / "save_olean_project", project_root) + shutil.copytree( + repo_root / "tests" / "save_olean_project", + project_root, + ignore=shutil.ignore_patterns(".beam"), + ) ready_file = tmp_path / "ready.json" child_stderr_file = tmp_path / "lean-beam-mcp.stderr" workspace = {"root": str(project_root.resolve())} diff --git a/tests/test-mcp-stdio.py b/tests/test-mcp-stdio.py index 07542153..adcde7e4 100644 --- a/tests/test-mcp-stdio.py +++ b/tests/test-mcp-stdio.py @@ -1866,6 +1866,20 @@ def run_concurrent_dispatch(repo_root, fixture_root, timeout, server_trace=False require(isinstance(slow_structured, dict), f"slow runAt missing structured content: {slow_result}") require_success("slow concurrent runAt", slow_structured) expect_result(client.request("ping", request_id=slow_id)) + async_reuse_id = "async-tool-request-id-reuse" + for reuse_iteration in range(64): + version_result = expect_result( + client.request( + "tools/call", + {"name": "beam_version", "arguments": {}}, + request_id=async_reuse_id, + ) + ) + require( + version_result.get("isError") is not True, + f"async request-ID reuse tool call {reuse_iteration} failed: {version_result}", + ) + expect_result(client.request("ping", request_id=async_reuse_id)) require( len(status_log_notifications(client, slow_id)) == status_count, f"slow no-token runAt emitted duplicate or post-response statuses: {client.notifications}", @@ -2075,7 +2089,44 @@ def run_concurrent_dispatch(repo_root, fixture_root, timeout, server_trace=False eof_request_id = "eof-inflight" client.send_request("tools/call", slow_params, request_id=eof_request_id) wait_for_file(started_path, timeout, "EOF runAt gate sentinel") + eof_drop_token = "eof-workspace-drop-progress" + eof_drop_id = client.send_request( + "tools/call", + { + "name": "lean_drop_workspace", + "arguments": {"workspace": workspace_descriptor(project_root)}, + "_meta": {"progressToken": eof_drop_token}, + }, + request_id="eof-workspace-drop", + ) + wait_for_progress_notification( + client, + eof_drop_token, + min(timeout, 5.0), + "EOF workspace drop admission", + ) client.close_input() + eof_drop_result = expect_result(client.read_response(eof_drop_id)) + require( + eof_drop_result.get("isError") is not True, + f"workspace drop admitted before EOF failed: {eof_drop_result}", + ) + eof_dropped = eof_drop_result.get("structuredContent") + require( + isinstance(eof_dropped, dict) and eof_dropped.get("dropped") is True, + f"workspace drop admitted before EOF did not evict the runtime: {eof_drop_result}", + ) + require_progress_sequence( + client.progress_notifications(eof_drop_token), + eof_drop_token, + "EOF workspace drop progress", + ) + returncode = client.wait_for_exit_after_eof(timeout) + require(returncode == 0, f"legacy server exited with code {returncode} after active EOF") + require( + not client.response_ready(eof_request_id), + "request cancelled by EOF produced a terminal response", + ) client.forget_request(eof_request_id) finally: if not release_path.exists(): @@ -3126,53 +3177,117 @@ def run_legacy_eof_teardown(repo_root, fixture_root, timeout): client.close() +def require_clean_exit_after_closed_stdout(client, message, label): + try: + client.proc.stdout.close() + client.send_message(message) + client.close_input() + try: + client.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + client.proc.kill() + fail(f"lean-beam-mcp did not exit after stdout was closed during {label}") + client.stderr_thread.join(timeout=1) + stderr = "\n".join(client.stderr_lines) + require( + client.proc.returncode == 0, + f"lean-beam-mcp exited with {client.proc.returncode} during {label}\n{stderr}", + ) + if client.server_trace: + unexpected = [ + line for line in stderr.splitlines() + if not line.startswith("lean-beam-mcp trace ") + ] + require( + not unexpected, + f"lean-beam-mcp wrote unexpected non-trace stderr during {label}:\n" + + "\n".join(unexpected), + ) + else: + require( + stderr.strip() == "", + f"lean-beam-mcp wrote unexpected stderr during {label}:\n{stderr}", + ) + finally: + if client.proc.poll() is None: + client.proc.kill() + client.proc.wait(timeout=5) + + def run_closed_stdout_regression(repo_root, fixture_root, timeout): with tempfile.TemporaryDirectory(prefix="lean-beam-mcp-closed-stdout-") as tmp: project_root = Path(tmp) / "project" copy_project_fixture(fixture_root, project_root) - client = McpClient( + initialize_client = McpClient( + repo_root, + project_root, + timeout, + label="closed-stdout-initialize", + drain_stdout=False, + ) + require_clean_exit_after_closed_stdout( + initialize_client, + { + "jsonrpc": "2.0", + "id": "closed-stdout-initialize", + "method": "initialize", + "params": initialize_params(), + }, + "initialize response", + ) + + control_client = McpClient( repo_root, project_root, timeout, - label="closed-stdout-regression", + label="closed-stdout-control", drain_stdout=False, ) - stderr = "" try: - client.proc.stdout.close() - client.send_message( + control_client.send_message( { "jsonrpc": "2.0", - "id": "closed-stdout", + "id": "closed-stdout-control-initialize", "method": "initialize", "params": initialize_params(), } ) - client.proc.stdin.close() - try: - client.proc.wait(timeout=5) - except subprocess.TimeoutExpired: - client.proc.kill() - fail("lean-beam-mcp did not exit after stdout was closed") - client.stderr_thread.join(timeout=1) - stderr = "\n".join(client.stderr_lines) - require(client.proc.returncode == 0, f"lean-beam-mcp exited with {client.proc.returncode}\n{stderr}") - if client.server_trace: - unexpected = [ - line for line in stderr.splitlines() - if not line.startswith("lean-beam-mcp trace ") - ] - require( - not unexpected, - "lean-beam-mcp wrote unexpected non-trace stderr after closed stdout:\n" - + "\n".join(unexpected), - ) - else: - require(stderr.strip() == "", f"lean-beam-mcp wrote unexpected stderr after closed stdout:\n{stderr}") + ready, _, _ = select.select([control_client.proc.stdout], [], [], timeout) + require(ready, "closed-stdout control regression did not receive initialize response") + initialized = json.loads(control_client.proc.stdout.readline()) + require( + initialized.get("id") == "closed-stdout-control-initialize", + f"closed-stdout control regression received wrong initialize response: {initialized}", + ) + expect_result(initialized) + control_client.send_message( + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + } + ) + require_clean_exit_after_closed_stdout( + control_client, + { + "jsonrpc": "2.0", + "id": "closed-stdout-control", + "method": "tools/call", + "params": { + "name": "lean_drop_workspace", + "arguments": { + "workspace": workspace_descriptor(project_root), + }, + "_meta": { + "progressToken": "closed-stdout-control-progress", + }, + }, + }, + "workspace-control response", + ) finally: - if client.proc.poll() is None: - client.proc.kill() - client.proc.wait(timeout=5) + if control_client.proc.poll() is None: + control_client.proc.kill() + control_client.proc.wait(timeout=5) def main(): diff --git a/tests/test-stage0-toolchain.sh b/tests/test-stage0-toolchain.sh index 020d3699..b7f7a5e6 100755 --- a/tests/test-stage0-toolchain.sh +++ b/tests/test-stage0-toolchain.sh @@ -11,6 +11,8 @@ cd "$(dirname "$0")/.." . tests/lib/assertions.sh # shellcheck source=tests/lib/tmp-guards.sh . tests/lib/tmp-guards.sh +# shellcheck source=tests/lib/wait.sh +. tests/lib/wait.sh toolchain="${BEAM_STAGE0_TOOLCHAIN:-lean4-stage0}" host_home="$HOME" @@ -63,7 +65,15 @@ assert_output_contains "stage0 custom toolchain doctor output" "$doctor_out" 'pr assert_output_contains "stage0 custom toolchain doctor output" "$doctor_out" 'bundle source: installed' assert_output_contains "stage0 custom toolchain doctor output" "$doctor_out" 'bundle toolchain fingerprint: ' +stage0_owner_err="$tmp_root/stage0-owner.err" ELAN_HOME="$host_elan_home" \ - "$install_home/.local/bin/lean-beam" --root "$project_root" ensure >/dev/null + "$install_home/.local/bin/lean-beam" --root "$project_root" ensure --hold \ + >/dev/null 2>"$stage0_owner_err" & +stage0_owner_pid="$!" +if ! wait_for_file_text "$stage0_owner_err" "owning Beam session" "stage0 session owner" 600 0.1; then + exit 1 +fi ELAN_HOME="$host_elan_home" \ "$install_home/.local/bin/lean-beam" --root "$project_root" shutdown >/dev/null +wait_for_exit "$stage0_owner_pid" "stage0 session owner" 120 0.1 +wait "$stage0_owner_pid"