Skip to content

fix(sdk): heal leaked conversation store locks instead of timing out - #4181

Open
Yeachan-Heo wants to merge 6 commits into
devfrom
fix/sdk-conversation-store-lock-leak-heal
Open

fix(sdk): heal leaked conversation store locks instead of timing out#4181
Yeachan-Heo wants to merge 6 commits into
devfrom
fix/sdk-conversation-store-lock-leak-heal

Conversation

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Summary

Fixes the shared notification ConversationLockTimeout regressions observed on current dev:

Both rows timed out on an effects.json.lock inside their own test process — no other process touched that tempdir.

Root cause

The file-lock release in ConversationStore (#withLock) is not idempotent and no waiter can recover a leaked lock whose recorded pid is still alive:

  1. Release can leak. The finally runs close() then unlink(). If close() throws, the unlink is skipped entirely, leaving a well-formed lock file on disk whose recorded pid is the still-running test process.
  2. A live-pid lock is unreclaimable. #isStaleLock only reclaims dead pids, mismatched incarnations, or empty files older than 30s. A well-formed lock from the current (alive) process is never stale, so every later acquire waits the full 1000ms and throws — exactly the CI signature. Deterministically reproduced by planting a live-pid lock file: the write times out at 1001ms and the lock stays.

The failure is shared infrastructure: ConversationStore backs both the conversation mappings and the ChatEffectJournal effects.json for Discord and Slack alike.

Fix (shared, in conversation-store.ts)

  • Idempotent release: a failed close() no longer skips the unlink; the unlink is ownership-checked (pid + incarnation + timestamp + a new per-acquire nonce) so it can only remove the exact lock this holder wrote — it can never delete a successor's lock.
  • Same-process leak self-heal: a module-level held-lock set tracks every lock this process currently holds across all store instances. A waiter that finds a well-formed lock recorded under this process's own pid that is not in the held set can prove it is a leaked lock and removes it instead of waiting out the timeout. Live same-process holders are never touched; cross-process locks are never touched.
  • No timeout inflation — the 1000ms window is unchanged.
  • No blind lock deletion — every unlink is either ownership-checked (release) or evidence-based (same-process, not-held leak heal).

Verification

  • 3 new regression tests in test/sdk-daemon-concurrency.test.ts:
    • heals a leaked live-pid lock instead of timing out
    • does not heal a lock a same-process instance currently holds (waiter waits for the live holder)
    • does not heal a well-formed lock held by another live process (still times out, lock preserved)
  • Full Discord daemon suite: 52/52 pass; Slack daemon suite: 49/49 pass.
  • 12-file lock-consumer shard batch (concurrency, both daemons, daemon CLI, control frames, worker, session reconnect, control, timeout validation, slack live provider/thread binding/thread state): 390/390 pass.
  • Pre-fix instrumentation stress (80 runs, 3080 lock ops): no in-process holder overlap; the leak is the only path that yields the 1000ms signature, and it is now healed.

Notes

  • The sdk-host.test.ts failures in the same shard runs ("SDK host logs a bounded reason…" 15s/60s timeouts) are a separate, unrelated flake and are out of scope here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b35689a57e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (fileLock) await fileLock.close().catch(() => undefined);
if (fileLock && lockToken) await this.#unlinkOwnedLock(lockFile, lockToken);
} finally {
heldLockFiles.delete(lockFile);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve a successor's held-lock marker during release

When another same-process ConversationStore acquires this path after #unlinkOwnedLock removes the old file but before this finally runs, that acquire adds the path to heldLockFiles and this unconditional deletion immediately removes the successor's marker. A third waiter can then classify the successor's live lock as a leak, unlink it, and enter the critical section concurrently, allowing conversation updates to overwrite one another. Track the acquire token in the registry and delete only the entry owned by this release.

Useful? React with 👍 / 👎.

Comment on lines +324 to +325
await this.#fs.unlink(lockFile).catch(() => undefined);
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not treat a failed leak unlink as successful

If deletion of a same-process leaked lock fails—for example, an unsuccessful close() leaves the handle open on Windows and unlink returns EPERM—this method suppresses the error and returns true. The acquire loop consequently retries immediately via continue, bypassing both its timeout check and sleep on every iteration, so the daemon spins indefinitely instead of recovering or raising ConversationLockTimeoutError. Return true only after deletion succeeds or the path is confirmed absent.

Useful? React with 👍 / 👎.

Comment on lines +356 to +359
// A leaked reclaim lock from this process must not block reclaim of a
// provably stale main lock. The double-reclaim window this opens is
// benign: both reclaimers unlink the same stale main lock idempotently.
if (await this.#reclaimSameProcessLeak(reclaimFile)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep live reclaim locks out of leak recovery

When two same-process store instances concurrently encounter the same stale main lock, the first instance creates and actively holds the .reclaim file through #createLockFile, but reclaim locks are never added to heldLockFiles. The second instance therefore treats that live mutex as leaked here, removes it, and creates another reclaimer. Both can then pass the stale recheck; one may delete the main path after the other has already replaced it with a live lock, breaking mutual exclusion and risking lost conversation updates. Reclaim-lock ownership must be registered just like main-lock ownership rather than deliberately permitting two reclaimers.

Useful? React with 👍 / 👎.

@Yeachan-Heo
Yeachan-Heo force-pushed the fix/sdk-conversation-store-lock-leak-heal branch from b35689a to 7d0c951 Compare August 10, 2026 13:55

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d0c951b10

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +323 to +324
if (heldLockFiles.has(lockFile)) return false;
await this.#fs.unlink(lockFile).catch(() => undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Serialize concurrent healing before unlinking the leaked lock

When two same-process waiters encounter the same leaked lock, both can read its metadata and pass this held-set check before either deletion completes. After the first waiter unlinks the leak and acquires a replacement, the second waiter's path-based unlink can delete that replacement; it then returns true, retries, and may acquire the path while the first waiter is still in its critical section, allowing conversation updates to overwrite each other. Reclaim the leak under an ownership-checked mutex or otherwise ensure the inode/token validated here is still the one being removed.

Useful? React with 👍 / 👎.

@Yeachan-Heo

Copy link
Copy Markdown
Owner Author

Signed exact-head CI verdict (head 7d0c951b10, run 31395411744, after rebase onto dev 7858b0ff63)

Conversation-lock scope: PASS.

  • The two previously-failing lock rows pass on CI with this fix:
    • Discord retries definite pre-send SDK and binding failures but preserves ambiguous sendspass (139ms) in shard-1
    • Slack replays an ACK-boundary command receipt with its persisted idempotency keypass in shard-2
  • test:packages/coding-agent/test/sdk-daemon-concurrency.test.ts (3 new lock regression tests + full suite) → pass
  • Every affected job and shard passes except shard-1, whose only failure is session-resident-lifecycle > moveTo materializes before cache reset ("Expected one active resident cache dir, got 2").
  • Zero ConversationLockTimeoutError in the entire run.

Shard-1 residual failure is external and separately owned. The same session-resident-lifecycle test fails on current dev's own CI (runs 31394748908, 31389891684, 31386889775) across shards 1/6/7, and it is repaired by PR #4183 (fix/dev-resident-sidecar-namespace at 5695a62c8e). This PR does not touch session/resident-cache code, and the dry-run rebase of this branch onto #4183's head replays cleanly (no conflicts).

Verdict: this PR is verification-complete for its own scope and holds pending #4183. Once #4183 lands into dev, rebase this branch onto the new dev head and rerun CI to confirm shard-1 goes green. No merge, no release.

@Yeachan-Heo
Yeachan-Heo force-pushed the fix/sdk-conversation-store-lock-leak-heal branch from 7d0c951 to 20f3a48 Compare August 10, 2026 23:56

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d6b454c732

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +325 to +326
const staleLock = await this.#readLock(lockFile);
if (!staleLock || !(await this.#isStaleLock(lockFile))) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve recovery for expired unpublished locks

When a process exits after open("wx") but before publishing valid metadata, the resulting empty or malformed lock is deliberately considered stale after 30 seconds by #isExpiredUnpublishedLock, but this new !staleLock return prevents it from ever being removed. Every later conversation mutation therefore times out until the artifact is manually deleted, whereas the previous implementation reclaimed it; preserve an ownership-safe recovery path for expired unpublished locks without requiring parseable metadata.

Useful? React with 👍 / 👎.

Comment on lines 380 to 382
this.#releaseHeldLock(lockFile, lock);
await handle.close().catch(() => undefined);
await this.#fs.unlink(lockFile).catch(() => undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retain ownership through failed creation cleanup

If metadata writing succeeds but handle.sync() rejects, this path removes the held marker before awaiting close() and then unconditionally unlinks the path. During that await, another same-process store can classify the published lock as leaked, remove it, and acquire a replacement; the failed creator then deletes that successor at line 382, allowing another writer into the critical section concurrently. Keep the token registered through cleanup and ownership-check the cleanup unlink.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c815f86a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

*/
async #reclaimSameProcessLeak(lockFile: string): Promise<boolean> {
const expected = await this.#readLock(lockFile);
if (!expected || expected.pid !== this.#pid || heldLockFiles.has(lockFile)) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Canonicalize registry keys before healing locks

When two store instances address the same underlying directory through different path aliases—for example, a symlink and its real path or differently cased paths on Windows—the live holder is registered under one raw string while this lookup uses the other. The waiter consequently classifies the live same-process lock as leaked, unlinks it after matching its metadata, and enters the critical section concurrently, allowing conversation updates to overwrite each other. Key the held-lock registry by a canonical filesystem identity rather than the caller-supplied path.

Useful? React with 👍 / 👎.

Yeachan Heo added 5 commits August 11, 2026 00:20
…me-process leaks

The Discord fake-provider retry/ambiguous-send row (PR #4153, CI run
31387018881) and the Slack ACK-boundary replay row (PR #4098, earlier runs)
both flaked with a 1000ms ConversationLockTimeoutError on an isolated
effects.json.lock inside their own test process. Root cause: a well-formed
lock file whose recorded pid is still alive is unreclaimable by any waiter
(staleness only covers dead pids and empty files), so a release that failed
between close() and unlink() -- or any interrupted release -- turns every
later acquire into a full 1000ms timeout. The failure reproduces
deterministically by planting a live-pid lock file.

Fix, shared by every ConversationStore user (conversation mappings and the
effects.json ChatEffectJournal alike):
- release is idempotent: a failed close() no longer skips the unlink, and
  the unlink is ownership-checked (pid+incarnation+timestamp+nonce) so it
  can only remove the lock this holder wrote
- waiters self-heal same-process leaks: a well-formed lock recorded under
  this process's own pid with no live holder (module-level held set) is
  removed instead of waiting out the timeout
- the 1000ms lock timeout is unchanged and live cross-process locks are
  never deleted

Verified with 3 new regression tests in sdk-daemon-concurrency.test.ts, the
full Discord (52) and Slack (49) daemon suites, and the 12-file lock-consumer
shard batch (390 tests).

Constraint: no timeout inflation -- the 1000ms lock window is untouched
Constraint: no blind lock deletion -- only provably leaked same-process locks are removed
Rejected: raising the lock timeout | masks the leak instead of fixing it
Rejected: unconditional waiter-side unlink | could delete a live holder's lock
Confidence: high
Scope-risk: medium
Reversibility: revert-safe
Tested: leaked-lock heal, live same-process holder protection, cross-process lock preservation
Not-tested: cross-process leak heal (holder process must exit first; unchanged behavior)
A close failure must not leave the exclusive conversation-store lock behind.\n\nThe regression exercises the real release path and confirms a later write can acquire the lock.\n\nConfidence: high\nScope-risk: low\nReversibility: revert-safe\nTested: bun test packages/coding-agent/test/sdk-daemon-concurrency.test.ts; bun --cwd=packages/coding-agent run check
Concurrent same-process waiters could each classify a leaked lock as removable and delete a successor.\n\nAn ownership-tracked reclaim lock now serializes healing, preserves successor markers, and returns to the configured timeout path when unlink cannot complete.\n\nConstraint: retain the 1000ms lock timeout\nConstraint: never delete a successor lock\nRejected: blind unlink retry | permits concurrent critical sections\nConfidence: high\nScope-risk: medium\nReversibility: revert-safe\nTested: bun test packages/coding-agent/test/sdk-daemon-concurrency.test.ts; bun test packages/coding-agent/test/session-resident-lifecycle.test.ts; bun --cwd=packages/coding-agent run check
Reclaim markers must recover safely after interrupted publication without letting cleanup poison later same-process recovery.\n\nNested cleanup now always clears its ownership marker, and stale auxiliary locks are reclaimed under their own fence.\n\nConstraint: preserve live reclaim locks\nConstraint: recover expired unpublished lock files\nConfidence: high\nScope-risk: medium\nReversibility: revert-safe\nTested: bun test packages/coding-agent/test/sdk-daemon-concurrency.test.ts; bun --cwd=packages/coding-agent run check
A failed stale-lock unlink must honor the configured acquisition deadline rather than escaping as a raw filesystem error.\n\nConstraint: preserve the existing 1000ms timeout contract\nConfidence: high\nScope-risk: low\nReversibility: revert-safe\nTested: bun test packages/coding-agent/test/sdk-daemon-concurrency.test.ts; bun test packages/coding-agent/test/session-resident-lifecycle.test.ts; bun --cwd=packages/coding-agent run check
@Yeachan-Heo
Yeachan-Heo force-pushed the fix/sdk-conversation-store-lock-leak-heal branch from 6c815f8 to d445319 Compare August 11, 2026 00:21

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d445319d33

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

parsed.pid !== expected.pid ||
parsed.incarnation !== expected.incarnation ||
parsed.timestamp !== expected.timestamp ||
parsed.nonce !== expected.nonce

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject non-string lock nonces before ownership comparison

If a stale lock contains a valid JSON object or array in nonce—for example, from corruption or an incompatible writer—isConversationStoreLock accepts it, but each read creates a distinct object reference, so this comparison always fails. #unlinkOwnedLock then refuses to remove the stale lock and every subsequent conversation mutation times out; validate nonce as either absent or a string before treating the metadata as a ConversationStoreLock.

Useful? React with 👍 / 👎.

fs.files.set(`${store.filePath}.lock`, leaked);
const started = performance.now();
await expect(store.write("mapping", undefined, record(1))).resolves.toBe(true);
expect(performance.now() - started).toBeLessThan(900);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove the wall-clock threshold from the healing test

On a loaded or temporarily descheduled CI worker, the correct immediate-healing implementation can take more than 900 ms between these measurements and fail this test nondeterministically. The preceding resolved-write assertion already distinguishes healing from the timeout path, which rejects, so this redundant timing assertion should be removed or replaced with a deterministic injected-sleep observation.

AGENTS.md reference: AGENTS.md:L156-L160

Useful? React with 👍 / 👎.

A failed lock sync must retain ownership through cleanup so a concurrent same-process waiter cannot reclaim and lose a successor.\n\nConstraint: failed publication must not weaken live-lock safety\nConfidence: high\nScope-risk: low\nReversibility: revert-safe\nTested: bun test packages/coding-agent/test/sdk-daemon-concurrency.test.ts; bun --cwd=packages/coding-agent run check

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3094c45341

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +377 to +382
const lock: ConversationStoreLock = {
pid: this.#pid,
incarnation: this.#pidIncarnation(this.#pid) ?? "unavailable",
timestamp: this.#clock(),
nonce: randomUUID(),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Move lock-token construction inside the cleanup scope

If the injected pidIncarnation callback throws—or randomUUID() fails—after open("wx") succeeds, the exception occurs before the try block, so the handle is never closed and the newly created empty lock file is never removed. Later mutations then time out until unpublished-lock recovery becomes eligible after 30 seconds; on platforms that prevent unlinking open files, the leaked handle can keep the store blocked indefinitely. Include token construction in the guarded cleanup path.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant