fix: recover the workflow server across suspend and wake - #107
Merged
Merged
Conversation
Co-Authored-By: GLM-5.3-Flash · Baseten <noreply@pi.dev> Generated-By: pi 0.87.1 (https://pi.dev)
After a macOS suspend the server killed itself on the first heartbeat because renewServer required an unexpired lease. Its shutdown-bound process kept the server lock while deaf, and the client never re-spawned a replacement that lost the lock race, so every open session warned 'Workflow server did not become ready: ... already running with PID X' after each wake. - renewServer re-arms the authenticated owner's expired lease by matching the full recorded identity; expiry gates takeover and never kills the owner, and a superseded epoch still stops the server. - acquireServerLock moves to src/server/lock.ts and probes the recorded holder's socket with a bounded hello check before reporting 'already running'; a dead or provably not-serving holder loses the lock, and the epoch claim stays the fencing authority. - ensureAvailable re-spawns a replacement when a spawned child exits without becoming ready, capped at three attempts inside the start window, so one lost race costs milliseconds instead of the full 10s. - Document the wake recovery behavior in docs/WORKFLOWS.md. Co-Authored-By: GLM-5.3-Flash · Baseten <noreply@pi.dev> Generated-By: pi 0.87.1 (https://pi.dev)
pi-reviewer found that the awaited serving probe lets concurrent replacements all pass the lock check, and a claim loser's unconditional socket cleanup could unlink the winner's bound socket and leave the successfully started server unreachable. - acquireServerLock revalidates the lock record after the probe and converges on a newer takeover instead of clobbering it, retries the exclusive create when a racer wins, and bounds the loop. - The server removes the socket file only when it actually bound it, so a fenced starter that loses the claim leaves the winner's socket in place. - Tests: probe-window revalidation and convergence, plus a real-server repro where a frozen holder keeps serving after a fenced starter loses the claim. Co-Authored-By: GLM-5.3-Flash · Baseten <noreply@pi.dev> Generated-By: pi 0.87.1 (https://pi.dev)
The second review round found the reverse of the first race: when a frozen holder's expired lease is taken over, the replacement claims the epoch and rebinds the socket path before the holder wakes. When the holder then stops, closing its listener - and exiting at all - unlinks the socket path, which now belongs to the replacement, leaving the replacement running but unreachable. - The server re-creates the socket file within one poll tick when the file disappears while the claim row still names it, and stops honestly if the rebind fails. The gated explicit removal and the takeover revalidation stay. - Test: the frozen holder wakes after the replacement binds, its stop removes the path, and the replacement restores the file and keeps serving. Co-Authored-By: GLM-5.3-Flash · Baseten <noreply@pi.dev> Generated-By: pi 0.87.1 (https://pi.dev)
A probe misfire against a live serving holder let a candidate replace the holder's lock record; the epoch claim then fenced the candidate, and its cleanup removed its own record, leaving the still-serving holder without a lock file and breaking the version-mismatch stop path. - acquireServerLock returns the live holder record it displaced. - A start whose claim attempt failed restores the displaced record when the lock file still names it, so the serving holder keeps its lock. - Tests: the fenced-starter scenario asserts the holder's record is restored, and the takeover tests assert the displaced record. Co-Authored-By: GLM-5.3-Flash · Baseten <noreply@pi.dev> Generated-By: pi 0.87.1 (https://pi.dev)
The handover tests wrote the claim row after SIGSTOP-ing the holder, and a holder frozen mid-write holds the SQLite lock, so the write could hit SQLITE_BUSY and fail on CI. Rewrite the lease while the holder is still running, freeze immediately after, and retry the write through busy windows in every lease-rewriting test. Co-Authored-By: GLM-5.3-Flash · Baseten <noreply@pi.dev> Generated-By: pi 0.87.1 (https://pi.dev)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every laptop wake used to print warning storms in every open Pi session.
After sleep the workflow server killed itself because its lease looked expired, its half-dead process still held the lock file, and clients never retried a failed replacement, so each session warned
Workflow server did not become ready: A workflow server is already running with PID Xwith a new PID every wake.This change keeps the server serving across suspend, makes lock takeover require proof that the holder is not serving, and makes clients re-spawn failed replacements within seconds.
Plan:
docs/plans/2026-09-27-wake-recovery-plan.md.What Changed
The root cause was a chain, and each link got its own fix.
renewServerinsrc/server/state.tsre-arms the authenticated owner's expired lease by matching the full recorded identity (epoch, server id, token hash, pid, process start identity) instead of requiringexpires_at > now. Expiry now gates takeover; it never kills the owner. A superseded epoch or a wrong token still fails the renewal and stops the server, so fencing is unchanged.acquireServerLockmoved tosrc/server/lock.tsand probes the recorded holder's socket with a bounded hello check before reportingalready running. A live serving holder keeps the error; a dead or shutdown-bound holder loses the lock and the new server takes over. The epoch claim row stays the fencing authority, so a probe misfire costs one wasted start, never two serving servers.ensureAvailableinsrc/client/client.tsre-spawns a replacement when the spawned child exits without becoming ready, with a short grace for a competing holder and a cap of three attempts inside the existing 10-second window. The thrown error contract is unchanged.docs/WORKFLOWS.mddocuments the wake recovery behavior, including thatexpires_atanswers whether the lease is valid, not whether the owner is alive.src/extension/index.tsis unchanged; the warning text stays accurate.Testing
The repository checks pass on this Mac with a short temp root because the default macOS
TMPDIRmakes unix socket paths exceed the 103-byte limit.TMPDIR=/tmp/e2e npm run check: format, lint, typecheck, and build pass; vitest reports 1433 passing tests with 15 failures that a detached base worktree atbf32dc5reproduces as a superset of 19 across 10 files, so this change adds no new failures.TMPDIR=/tmp/e2e npm run test:e2e: 3 files / 14 tests pass against the real Pi runtime.npx slophammer-ts@latest dry .and the dependency-boundary check pass.Not tested here: the real-model live E2E from
AGENTS.mdruns as a separate manual validation. CI covers the unit suite in its normal environment.Risks
A serving holder with a temporarily blocked event loop can fail the 500 ms probe and lose the lock. The epoch claim fences the outcome: the robber exits while the holder's lease is live, or the holder exits when its renewal fails, so the worst case is one wasted spawn, never two serving servers. An expired lease no longer implies a dead owner, so
expires_atreaders were audited to keep their lease-validity meaning. A short handover window can have two listening sockets, which is the pre-existing lease-granularity behavior.Follow-ups
Adopt the fixed release in OnurPi (
~/repos/onurpipins@osolmaz/pi-workflows0.17.4) so running sessions pick it up. Later, claim-only fencing with a serving-proof heartbeat and recovery telemetry can remove the lock file entirely.