Skip to content

Commit f4be291

Browse files
committed
fix: improve startup and feedback diagnostics
1 parent 806a2a7 commit f4be291

18 files changed

Lines changed: 287 additions & 46 deletions

‎Beam/Broker/Server.lean‎

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,55 @@ namespace Beam.Broker
3838
abbrev brokerStdio : IO.Process.StdioConfig where
3939
stdin := .piped
4040
stdout := .piped
41-
-- Keep backend stderr away from MCP stdio. Inheriting it can corrupt
42-
-- client framing; piping and draining it caused macOS save_olean hangs.
43-
stderr := .null
41+
-- Keep backend stderr away from MCP stdio while retaining a bounded tail for
42+
-- startup and worker-exit diagnostics. The blocking drain runs on a dedicated
43+
-- task so it cannot starve regular Lean tasks.
44+
stderr := .piped
45+
46+
private def backendStderrTailLimit : Nat :=
47+
16 * 1024
48+
49+
private def backendStderrReadSize : USize :=
50+
4096
51+
52+
private def isUtf8ContinuationByte (byte : UInt8) : Bool :=
53+
decide (128 ≤ byte.toNat ∧ byte.toNat < 192)
54+
55+
private def utf8BoundaryAtOrAfter (bytes : ByteArray) (offset : Nat) : Nat :=
56+
let rec loop (offset : Nat) : Nat → Nat
57+
| 0 => offset
58+
| fuel + 1 =>
59+
if h : offset < bytes.size then
60+
if isUtf8ContinuationByte bytes[offset] then
61+
loop (offset + 1) fuel
62+
else
63+
offset
64+
else
65+
offset
66+
loop offset 3
67+
68+
structure BackendStderrCapture where
69+
tail : Std.Mutex ByteArray
70+
drainTask : Task (Except IO.Error Unit)
71+
72+
private partial def drainBackendStderr
73+
(stderr : IO.FS.Handle)
74+
(tail : Std.Mutex ByteArray) : IO Unit := do
75+
let chunk ← stderr.read backendStderrReadSize
76+
unless chunk.isEmpty do
77+
tail.atomically do
78+
let combined := (← get) ++ chunk
79+
if combined.size > backendStderrTailLimit then
80+
let start := utf8BoundaryAtOrAfter combined (combined.size - backendStderrTailLimit)
81+
set <| combined.extract start combined.size
82+
else
83+
set combined
84+
drainBackendStderr stderr tail
85+
86+
def startBackendStderrCapture (stderr : IO.FS.Handle) : IO BackendStderrCapture := do
87+
let tail ← Std.Mutex.new ByteArray.empty
88+
let drainTask ← IO.asTask (prio := Task.Priority.dedicated) <| drainBackendStderr stderr tail
89+
pure { tail, drainTask }
4490

4591
structure Session where
4692
workspaceId : WorkspaceId
@@ -51,6 +97,7 @@ structure Session where
5197
proc : IO.Process.Child brokerStdio
5298
stdin : IO.FS.Stream
5399
stdout : IO.FS.Stream
100+
stderrCapture : BackendStderrCapture
54101
pending : PendingRequestStore
55102
nextId : Nat := 1
56103
nextEventSeq : Nat := 1
@@ -155,6 +202,31 @@ private partial def waitForTaskWithTimeout
155202
loop (remainingMs - min pollMs remainingMs)
156203
loop timeoutMs
157204

205+
private def backendName : Backend → String
206+
| .lean => "Lean"
207+
| .rocq => "Rocq"
208+
209+
private def BackendStderrCapture.snapshot (capture : BackendStderrCapture) : IO String := do
210+
let bytes ← capture.tail.atomically get
211+
pure <| (String.fromUTF8? bytes).getD "<backend stderr tail is not valid UTF-8>"
212+
213+
private def BackendStderrCapture.awaitDrain
214+
(capture : BackendStderrCapture)
215+
(timeoutMs : Nat := 500) : IO Unit := do
216+
discard <| waitForTaskWithTimeout capture.drainTask timeoutMs
217+
218+
private def backendFailureMessage
219+
(backend : Backend)
220+
(phase cause : String)
221+
(capture : BackendStderrCapture) : IO String := do
222+
let stderr := (← capture.snapshot).trimAscii.toString
223+
let stderr := if stderr.isEmpty then "<empty>" else stderr
224+
pure <| String.intercalate "\n" [
225+
s!"{backendName backend} backend failed {phase}: {cause}",
226+
s!"backend stderr tail (last {backendStderrTailLimit} bytes):",
227+
stderr
228+
]
229+
158230
private def sessionShutdownReplyTimeoutMs : Nat :=
159231
1000
160232

@@ -209,6 +281,25 @@ private def terminateBackendProcess (proc : IO.Process.Child brokerStdio) : IO U
209281
catch _ =>
210282
pure ()
211283

284+
private def startBackendStderrCaptureOrTerminate
285+
(backend : Backend)
286+
(proc : IO.Process.Child brokerStdio) : IO BackendStderrCapture := do
287+
try
288+
startBackendStderrCapture proc.stderr
289+
catch err =>
290+
terminateBackendProcess proc
291+
throw <| IO.userError <|
292+
s!"{backendName backend} backend failed during startup before stderr capture: {err}"
293+
294+
private def terminateBackendFailure
295+
(backend : Backend)
296+
(phase cause : String)
297+
(proc : IO.Process.Child brokerStdio)
298+
(capture : BackendStderrCapture) : IO String := do
299+
terminateBackendProcess proc
300+
capture.awaitDrain
301+
backendFailureMessage backend phase cause capture
302+
212303
private def sessionExited (session : Session) : IO Bool := do
213304
try
214305
pure (← session.proc.tryWait).isSome
@@ -453,11 +544,13 @@ partial def sessionReaderLoop (session : Session) : IO Unit := do
453544
pure ()
454545
sessionReaderLoop session
455546
catch e =>
547+
let message ←
548+
terminateBackendFailure session.backend "after startup" e.toString
549+
session.proc session.stderrCapture
456550
PendingRequestStore.failAll session.pending <| BrokerFailure.toResponseFailure {
457551
code := .workerExited
458-
message := e.toString
552+
message
459553
}
460-
terminateBackendProcess session.proc
461554

462555
private def startRequestJsonTrackedDetailed
463556
(session : Session)
@@ -589,6 +682,7 @@ private def acquireBackendSession
589682
env := env
590683
cwd := root.toString
591684
}
685+
let stderrCapture ← startBackendStderrCaptureOrTerminate backend proc
592686
let (session, initializeTask) ←
593687
try
594688
let stdin := IO.FS.Stream.ofHandle proc.stdin
@@ -604,6 +698,7 @@ private def acquireBackendSession
604698
proc
605699
stdin
606700
stdout
701+
stderrCapture
607702
pending
608703
}
609704
writeLspRequest stdin
@@ -613,8 +708,8 @@ private def acquireBackendSession
613708
awaitInitializeResponse stdout
614709
pure (session, initializeTask)
615710
catch err =>
616-
terminateBackendProcess proc
617-
throw err
711+
throw <| IO.userError <| ←
712+
terminateBackendFailure backend "during startup" err.toString proc stderrCapture
618713
try
619714
match ← waitForTaskWithTimeout initializeTask backendInitializeTimeoutMs with
620715
| some (.ok ()) => pure ()
@@ -632,9 +727,10 @@ private def acquireBackendSession
632727
pure session
633728
catch err =>
634729
IO.cancel initializeTask
635-
terminateBackendProcess proc
730+
let message ←
731+
terminateBackendFailure backend "during startup" err.toString proc stderrCapture
636732
discard <| waitForTaskWithTimeout initializeTask sessionShutdownReplyTimeoutMs
637-
throw err
733+
throw <| IO.userError message
638734

639735
private def requireWorkspace (workspaceId : WorkspaceId) : M WorkspaceState := do
640736
let state ← get

‎Beam/Cli/Commands.lean‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,8 @@ def runCommand (home : System.FilePath) (opts : CliOptions) : IO Unit := do
310310
printInstallLayout
311311
| "install-manifest" :: payloadHash :: sourceCommitArg :: createdWithToolchains =>
312312
printInstallManifest payloadHash sourceCommitArg createdWithToolchains
313+
| "install-manifest-with-source-commit" :: manifestPath :: sourceCommitArg :: [] =>
314+
printInstallManifestWithSourceCommit (System.FilePath.mk manifestPath) sourceCommitArg
313315
| "install-runtime-validate" :: path :: [] =>
314316
validateInstalledRuntimeForReuse (System.FilePath.mk path)
315317
| "mcp-config" :: [] =>

‎Beam/Cli/DaemonManager.lean‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1205,9 +1205,10 @@ def withProjectDaemonOwner
12051205
-- `.beam` state for a previously unseen toolchain.
12061206
preparePrivateControlDir controlDir
12071207
let desired ← desiredConfig home root backend
1208+
-- Allocate all fallible owner bookkeeping before the child and registry generation exist.
1209+
let exitCodeRef ← IO.mkRef (none : Option UInt32)
12081210
let owned ← withProjectControl root (explicitControlDir? := some controlDir) fun control =>
12091211
startOwnedProjectDaemon control desired backend opts
1210-
let exitCodeRef ← IO.mkRef (none : Option UInt32)
12111212
try
12121213
act {
12131214
client := owned.client

‎Beam/Cli/Info.lean‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -217,17 +217,25 @@ def printCompatibleReleaseLines (home : System.FilePath) : IO Unit := do
217217
def printInstallLayout : IO Unit := do
218218
printJsonLine (toJson installLayout)
219219

220+
private def sourceCommitArg? (sourceCommitArg : String) : Option String :=
221+
if sourceCommitArg == "-" then none else some sourceCommitArg
222+
220223
def printInstallManifest (payloadHash : String) (sourceCommitArg : String)
221224
(createdWithToolchains : List String) : IO Unit := do
222225
if createdWithToolchains.isEmpty then
223226
throw <| IO.userError
224227
"usage: beam install-manifest <payload-hash> <source-commit|-> <creation-toolchain...>"
225-
let sourceCommit? :=
226-
if sourceCommitArg == "-" then
227-
none
228-
else
229-
some sourceCommitArg
230-
printJsonLine (installManifestJson payloadHash sourceCommit? createdWithToolchains)
228+
printJsonLine (installManifestJson payloadHash (sourceCommitArg? sourceCommitArg)
229+
createdWithToolchains)
230+
231+
def printInstallManifestWithSourceCommit
232+
(manifestPath : System.FilePath)
233+
(sourceCommitArg : String) : IO Unit := do
234+
let manifest ← readInstallManifest manifestPath
235+
unless manifest.schemaVersion == installManifestSchemaVersion do
236+
throw <| IO.userError
237+
s!"cannot refresh source commit in install manifest schemaVersion {manifest.schemaVersion}"
238+
printJsonLine <| toJson { manifest with sourceCommit := sourceCommitArg? sourceCommitArg }
231239

232240
def printMcpConfig (home : System.FilePath) (opts : CliOptions) : IO Unit := do
233241
let root ← projectRoot opts .lean

‎Beam/Feedback.lean‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -391,9 +391,6 @@ private def optionalLine (label : String) (value? : Option String) : List String
391391
private def boolText (value : Bool) : String :=
392392
if value then "true" else "false"
393393

394-
private def shortCommit (commit : String) : String :=
395-
String.ofList <| commit.toList.take 12
396-
397394
private def jsonField? (json : Json) (field : String) : Option Json :=
398395
match json.getObjVal? field with
399396
| .ok value => some value
@@ -440,7 +437,7 @@ private def runtimeSummarySection (collection : Collection) : String :=
440437
| branch?, commit?, dirty? =>
441438
let parts :=
442439
(branch?.map (fun branch => s!"branch {branch}")).toList ++
443-
(commit?.map (fun commit => s!"commit {shortCommit commit}")).toList ++
440+
(commit?.map (fun commit => s!"commit {commit}")).toList ++
444441
(dirty?.map (fun dirty => s!"dirty {boolText dirty}")).toList
445442
some <| String.intercalate ", " parts
446443
let activeRoot? :=
@@ -460,6 +457,7 @@ private def runtimeSummarySection (collection : Collection) : String :=
460457
optionalLine "runtime active" ((jsonBoolField? identity "runtime_active").map boolText) ++
461458
optionalLine "runtime current" ((jsonBoolField? identity "runtime_current").map boolText) ++
462459
optionalLine "runtime error" (jsonStringField? identity "runtime_error") ++
460+
optionalLine "runtime payload" (jsonStringField? identity "runtime_payload") ++
463461
optionalLine "source" source? ++
464462
optionalLine "daemon endpoint" (jsonStringField? daemon "registryEndpoint") ++
465463
warningLines

‎Beam/Mcp/Projection.lean‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ private def evidenceInputSchema : Json :=
179179
("properties", Json.mkObj [
180180
("name", Beam.JsonSchema.string "Simple evidence filename, without path separators."),
181181
("content", anyJsonSchema "Inline JSON or text evidence to write into the bundle."),
182-
("path", Beam.JsonSchema.string "Path to a local evidence file under the known root or Beam control directory.")
182+
("path", Beam.JsonSchema.string
183+
"Path to a local evidence file under the known root or selected Beam session directory.")
183184
]),
184185
("required", toJson (#[("name" : String)] : Array String)),
185186
("additionalProperties", toJson false)

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ This project keeps a lightweight, reverse-chronological changelog. Dates use `YY
9090

9191
### Fixed
9292

93+
- Backend handshake and worker-exit failures now include a bounded stderr tail. Feedback report
94+
cards show the complete source commit and runtime payload, and reusing an installed runtime
95+
refreshes its source commit so reports identify the checkout that performed the install
96+
([#242](https://github.com/leanprover/lean-beam/pull/242), @ejgallego).
9397
- MCP `lean_run_at` and `lean_todo` again advertise the read-only hint used by approval- and
9498
concurrency-aware clients. Codex MCP registration also enables parallel tool calls so independent
9599
probes need not be serialized by the client.

‎docs/FEEDBACK.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,12 @@ descriptor. Beam validates that local target even when confidential mode omits p
105105
MCP returns compact report-card JSON in
106106
`structuredContent`: `markdown`, `metadata`, `collection_warnings`, and any bundle paths. The
107107
default Markdown includes a short Beam runtime summary instead of the full collected debug JSON,
108-
including stale-runtime or invalid-install identity when available. Pass `include_collected: true`
109-
to include the full collected Beam debug context inline and render the full debug-context section in
110-
Markdown. Non-confidential results echo the resolved `workspace` descriptor. Confidential results
111-
omit that descriptor; `include_collected: true` returns only the restricted runtime identity and
112-
cannot restore omitted project context.
108+
including the full source commit, runtime payload, and stale-runtime or invalid-install identity
109+
when available. Pass `include_collected: true` to include the full collected Beam debug context
110+
inline and render the full debug-context section in Markdown. Non-confidential results echo the
111+
resolved `workspace` descriptor. Confidential results omit that descriptor;
112+
`include_collected: true` returns only the restricted runtime identity and cannot restore omitted
113+
project context.
113114

114115
MCP does not start a Lean runtime just to collect feedback. In non-confidential mode it includes
115116
daemon registry and recent daemon incident context for the described workspace. When a runtime is

‎docs/SETUP.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,16 @@ lean-beam doctor
126126

127127
## Prune Old Installed State
128128

129-
The installer publishes each distinct content payload as an immutable runtime under
129+
The installer publishes each distinct content payload as an immutable runtime payload under
130130
`BEAM_INSTALL_ROOT/versions`. Reinstalling an identical payload reuses its existing runtime only
131131
after validating its ownership marker, manifest, required files, executable commands, and payload
132132
contents. The schema-3 manifest field `createdWithToolchains` records the toolchain selection that
133-
first created that immutable payload; later prebuilds add mutable bundle-cache entries without
134-
rewriting that provenance. Beam keeps prior distinct runtimes so publishing `current` stays atomic,
135-
but those snapshots are not removed automatically. Schema-2 manifests are readable only for
136-
identity and cleanup; reinstalling never republishes a schema-2 runtime. Preview old state with:
133+
first created that immutable payload. On reuse, the installer refreshes only the manifest's
134+
`sourceCommit` to the current source checkout commit, or clears it when no commit is available;
135+
`createdWithToolchains` remains unchanged. Later prebuilds add mutable bundle-cache entries without
136+
changing the runtime payload. Beam keeps prior distinct runtimes so publishing `current` stays
137+
atomic, but those snapshots are not removed automatically. Schema-2 manifests are readable only
138+
for identity and cleanup; reinstalling never republishes a schema-2 runtime. Preview old state with:
137139

138140
```bash
139141
lean-beam prune

‎docs/STATUS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,9 @@ Exact event ordering and examples live in
248248
service. Unix-domain/per-user native IPC remains a possible later transport improvement.
249249
- A startup failure that reports `operation not permitted` through `.beam/beam-daemon-startup.log` is
250250
usually an environment restriction, not a bundle-resolution mismatch.
251+
- Once the Beam daemon is running, a Lean or Rocq backend handshake failure is returned with the
252+
bounded tail of that backend's stderr. This backend diagnostic is separate from the selected
253+
session directory's daemon startup log, which covers startup of the Beam daemon process itself.
251254
- Typed broker transport, invalid-response, and response-timeout failures include registry/log
252255
context and write a JSON incident record below the selected session directory. Incident kinds are `brokerTransportFailure`,
253256
`invalidBrokerResponse`, and `brokerResponseTimeout`; callback/display failures do not create

0 commit comments

Comments
 (0)