Skip to content

fix(app-server): unsubscribe task threads after client disconnect - #707

Open
thossullivan wants to merge 9 commits into
openai:mainfrom
thossullivan:fix/unsubscribe-completed-task-threads
Open

fix(app-server): unsubscribe task threads after client disconnect#707
thossullivan wants to merge 9 commits into
openai:mainfrom
thossullivan:fix/unsubscribe-completed-task-threads

Conversation

@thossullivan

@thossullivan thossullivan commented Sep 1, 2026

Copy link
Copy Markdown

Summary

The app-server broker multiplexes plugin tasks through one shared upstream connection. Starting or resuming a thread subscribes that connection to thread events.

Before this change, closing a downstream task socket removed request and stream routing state but retained the thread subscription. Repeated tasks could therefore accumulate thread runtimes and MCP process trees inside one broker session.

This change tracks thread ownership for every downstream socket. The broker sends thread/unsubscribe only after the final downstream owner releases a thread, and it never lets a new claim race an unsubscribe that is still in flight.

Implementation

The broker maintains ownership in both directions. Downstream sockets reference thread IDs, and thread IDs reference their downstream owners. This structure preserves a shared subscription while another client still owns the thread.

Ownership covers started, resumed, forked, detached review, subagent, and automatically created child threads. Direct request results establish root ownership. Subagent notifications use parentThreadId or collaboration sender and receiver IDs to inherit ownership from the causal parent. They do not use whichever downstream client happens to be active when a delayed notification arrives, and a child whose cleanup is still outstanding is not handed to new owners.

Requests from each downstream socket run in order. This prevents overlapping resume or review requests from releasing another request's provisional claim. A response received after its downstream socket closes also triggers immediate cleanup.

A thread/unsubscribe that has already been sent is never reused. A later release chains a fresh request behind any in-flight one, waits for it to settle, and re-checks ownership before sending; releases that arrive while a request is still queued share that request instead of growing the chain. A resume, review, or explicit unsubscribe that touches a thread with an in-flight unsubscribe waits up to five seconds for it before it is dispatched. If that wait expires, the broker rejects the request with a retryable error and releases any provisional claim instead of racing or queuing behind the outstanding unsubscribe. Retried requests keep being rejected until the hung request settles. A failed request replies and clears the busy state before it releases its provisional claim, so a hung upstream cleanup cannot wedge the broker for other clients.

Normal socket closure and abrupt disconnection use the same final-owner cleanup path. Automatic unsubscribe failures are logged and retried with bounded backoff, including when an explicit unsubscribe fails after its requester has disconnected. A claim that is rejected or fails rolls back every thread it acquired, including subagent threads inherited while it was open. Explicit downstream unsubscribe requests continue to preserve subscriptions owned by other clients.

Scope

This change does not alter MCP configuration or depend on Basic Memory. Basic Memory only provided visible reproduction evidence for the host-level lifecycle defect.

Codex retains unsubscribed threads during its documented 30-minute inactivity period. This change starts that bounded cleanup period but does not promise immediate MCP process termination.

Testing

The integration suite covers normal completion, shared resumed threads, detached reviews, forks, active-turn disconnection, subagents, late child threads, collaboration-only child discovery, explicit unsubscribe, and upstream failures.

The adversarial regression cases cover delayed child notifications during an unrelated task, overlapping same-socket resume requests, reacquiring a thread during an in-flight unsubscribe, a hung upstream unsubscribe during a resume and during a failed request, retried claims and explicit unsubscribes against a hung cleanup, child and nested subagent rollback after a failed claim, cleanup retry after a disconnecting explicit unsubscribe, retry cancellation on reacquire, the retry bound, transient unsubscribe retries, and test resource cleanup.

Command Result
node --test tests/broker-subscriptions.test.mjs 22 passed
npm test 113 passed
npx tsc -p tsconfig.app-server.json --noEmit passed

Real-process verification

Codex CLI 0.150.1 with Basic Memory.

Case Probe after client closure Meaning Natural MCP exit
Control broker unsubscribed The broker retained the subscription until the probe removed it ~30 min after the probe
Patched broker notSubscribed The broker had already released the subscription ~30 min after client closure

Both cases started a real Basic Memory wrapper and Python server. Every isolated test descendant exited during cleanup.

After the review rounds, patched UAT covered both downstream closure modes, a companion task through the broker, and thread reacquisition.

Patched case Probe after client closure Natural MCP exit
Graceful socket closure notSubscribed 1,804,992 ms after closure
Abrupt socket disconnection notSubscribed 1,809,728 ms after disconnection
Companion task via broker, then reacquire and release notSubscribed after every release 1,809,137 ms after final closure

Three further runs of each reacquire scenario, without the exit watch, also returned notSubscribed after every release. In the reacquire runs, the reacquiring client's own thread/unsubscribe returned unsubscribed, which confirms its subscription was live. Brokers and app-server processes remained alive through natural MCP unload. Final cleanup left zero isolated descendants.

Changes since the first review

The first review found that a thread resumed while its automatic thread/unsubscribe was still in flight was never released again. The commits after 1203f5d close that race and the edges the automated review found around it. Each one is a small, named rule rather than a new subsystem, and each carries a regression test that fails on the commit before it.

Commit Rule Finding it closes
fc22719 A sent unsubscribe is never reused; a release chains a fresh request behind it and re-checks ownership. A provisional claim waits for in-flight cleanup of its thread. Error replies precede releases. in-flight reacquire race
f0ddd6b The wait is bounded to five seconds; on expiry the claim is rejected with a retryable error instead of racing the outstanding request. unbounded wait, timeout reopening the race
93b5660 A chained cleanup waits for its predecessor without a bound, so a pending entry never settles while an upstream request is outstanding. retries slipping past a hung cleanup
68b4029 Explicit unsubscribes use the same bounded gate. A release shares a still-queued request instead of growing the chain. A child with pending cleanup is not handed to new owners. explicit unsubscribe wedging the broker
fd45841, e7fba1e A claim rolls back every thread it acquired, including subagent threads and their descendants inherited while it was open. ownership left behind by failed claims
162f7e8 An explicit unsubscribe that fails after its requester disconnected falls back to the bounded automatic retry. Test fixture writes state atomically. cleanup dropped after disconnect, test flake

The broker never records an owner for a thread while cleanup for that thread is outstanding upstream, never lets a socket wait on cleanup without a bound, and rolls back everything a failed claim acquired. It does not depend on the app-server's per-thread request ordering. Codex review reports no findings at 162f7e8.

Related work

This change complements #543, #660, and #680. Those items address broker and app-server lifetime, while this change releases completed task threads inside a running broker. The change does not depend on either lifecycle pull request.

Fixes #706

@thossullivan
thossullivan marked this pull request as ready for review September 1, 2026 19:01
@thossullivan
thossullivan requested a review from a team September 1, 2026 19:01
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T13:39:04.409813Z 162f7e8 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@sylvesterkaczmarek sylvesterkaczmarek 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.

One race still looks possible here. addThreadOwner() cancels a retry timer, but it doesn't do anything about a thread/unsubscribe already in pendingUnsubscribes. If the last owner disconnects and that request is in flight while another client resumes the same thread, a late unsubscribe can leave the shared app-server connection unsubscribed even though threadSockets has a new owner.

I don't see a test for reacquiring a thread during an in-flight unsubscribe. Could we re-check ownership when the unsubscribe settles (and restore the subscription if needed), or otherwise serialize that transition?

@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: a4548b856d

ℹ️ 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 thread plugins/codex/scripts/app-server-broker.mjs Outdated
Comment thread plugins/codex/scripts/app-server-broker.mjs Outdated
@thossullivan
thossullivan force-pushed the fix/unsubscribe-completed-task-threads branch from a4548b8 to 1203f5d Compare September 2, 2026 21:59

@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: 1203f5d522

ℹ️ 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 thread plugins/codex/scripts/app-server-broker.mjs
A thread resumed while its automatic thread/unsubscribe was still in
flight was never released again: the later release reused the stale
pending request, so the shared app-server connection stayed subscribed
after every client had closed. If the app-server had processed the
unsubscribe after the resume, the new owner would also have lost its
subscription.

Never reuse a pending unsubscribe. A release chains a fresh request
behind any in-flight one and re-checks ownership before sending. A
resume or review that provisionally claims a thread waits, bounded to
five seconds, for an in-flight unsubscribe of that thread before it is
dispatched, so a late unsubscribe cannot overtake the new subscription
and a hung cleanup cannot wedge the broker. Reply to a failed request
and clear the busy state before releasing provisional owners for the
same reason.

Add the unsubscribe-delayed and resume-fails-unsubscribe-hangs fixture
behaviors, four regression tests, and assert the retry bound in the
existing failure test.

@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: fc227197c7

ℹ️ 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 thread plugins/codex/scripts/app-server-broker.mjs Outdated
When the bounded wait for an in-flight thread/unsubscribe expires, do
not send the resume anyway: that would race the outstanding unsubscribe
and could leave a tracked owner without an upstream subscription.
Reject the request with a retryable error, release the provisional
claim, and clear the busy state so other clients continue.

@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: f0ddd6b95b

ℹ️ 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 thread plugins/codex/scripts/app-server-broker.mjs
A release that followed a timed-out claim installed a fresh cleanup
wrapper that waited for the original unsubscribe for only another five
seconds, then settled as skipped once a retried claim owned the thread.
The retry could then resume upstream while the original unsubscribe was
still in flight. Make a wrapper wait for its predecessor without a
bound so the pending entry never settles before every underlying
request has, and reject retried claims until then.

@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: 93b5660d21

ℹ️ 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 thread plugins/codex/scripts/app-server-broker.mjs
An explicit thread/unsubscribe for an unowned thread queued behind a
hung automatic unsubscribe without a bound while its socket held the
busy slot, so every other client stayed busy. Apply the same bounded
wait and retryable rejection to explicit unsubscribes that the
provisional claims already use.

Two related gaps closed in the same sweep:

- A release that arrives while an earlier cleanup is still queued now
  shares that queued request instead of adding another link to the
  chain. A queued request re-checks ownership when it sends, so this
  is safe, and repeated retries against a hung upstream no longer grow
  an unbounded chain of duplicate unsubscribes.
- A child thread whose cleanup is still outstanding is no longer handed
  to new owners of its parent by a later notification.
@thossullivan

Copy link
Copy Markdown
Author

@codex review

@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: 68b4029524

ℹ️ 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 thread plugins/codex/scripts/app-server-broker.mjs Outdated
A child notification that arrived while a provisional resume or review
was waiting or in flight attributed the child to the claiming socket,
and a rejected or failed claim released only the parent. The child then
stayed owned until the socket closed. Record children inherited through
an open claim and release them together with the claim when it is
rejected or fails.

@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: fd45841d78

ℹ️ 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 thread plugins/codex/scripts/app-server-broker.mjs Outdated
…claim

A grandchild spawned while a provisional claim was open has an
inherited child as its source, not the claimed root, so it was owned
but omitted from the rollback. Record descendants whose source is
either a claimed root or an already inherited thread.
@thossullivan

Copy link
Copy Markdown
Author

@codex review

@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: e7fba1e2d0

ℹ️ 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 thread plugins/codex/scripts/app-server-broker.mjs
Comment thread tests/broker-subscriptions.test.mjs
…eaves

When the final owner sends thread/unsubscribe and disconnects before the
upstream request fails, nothing retried the cleanup: the owner was
already removed, so the close path had nothing to release, and the
explicit path restored ownership only to a still-open socket. Schedule
the bounded automatic retry in that case.

Test fixture: write state atomically so a test never reads a partial
document, and add a delayed single-failure unsubscribe behavior for the
new regression test.
@thossullivan

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 162f7e8d5c

ℹ️ 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".

@thossullivan

Copy link
Copy Markdown
Author

@sylvesterkaczmarek

Thank you so much for the review and for the catch! Confirmed, it did reproduce deterministically. With a fake app-server that delays the unsubscribe reply, "A closes, unsubscribe in flight, B resumes, B closes" reused the stale pending request, so no second unsubscribe was ever sent and the thread stayed subscribed.

The variant where a late unsubscribe overtakes the resume is not reachable on the real app-server, which serializes thread/unsubscribe and thread/resume per thread ID on one connection, but the leak variant is.

Since your review the branch gained several commits to cover several of the various edge cases, but they do implement one design rather than seven patches, and I have added a table of them to the description.

The rules:

  • A cleanup request that has been sent is never reused. A later release chains a fresh request behind it and re-checks ownership before sending. A release that arrives while a request is still queued shares that request. (fc22719, 68b4029)
  • Nothing acquires or touches a thread while its cleanup is outstanding. A resume, review, or explicit unsubscribe waits up to five seconds for in-flight cleanup of that thread. On expiry it is rejected with a retryable error and releases its claim, instead of racing or queuing behind the outstanding request. Retries keep being rejected until the hung request settles. (fc22719, f0ddd6b, 93b5660, 68b4029)
  • A claim is transactional. Everything it acquired, including subagent threads and their descendants inherited while it was open, rolls back with it when it is rejected or fails. (fd45841, e7fba1e)
  • The busy slot never waits on cleanup. Error replies precede releases. (fc22719)
  • Failed cleanup retries with bounded backoff, also when an explicit unsubscribe fails after its requester disconnected. (162f7e8)

I chose serialization over restoring the subscription from the broker. A broker-issued thread/resume would run with default parameters, can fail on its own, and races the client's next request. Nothing in the final code depends on the app-server's per-thread ordering.

Tests added for this thread of findings, each failing on the commit before its fix:

  • broker serializes a resume behind an in-flight unsubscribe
  • broker fails a resume when an in-flight unsubscribe outlives the bounded wait
  • broker keeps rejecting claims until a hung unsubscribe settles
  • broker rejects an explicit unsubscribe that would queue behind a hung cleanup
  • broker rolls back child threads inherited through a failed provisional claim
  • broker retries cleanup when a client disconnects during a failing explicit unsubscribe

The focused suite is 22 tests, npm test is 113 passed, and real-process UAT with Codex CLI 0.150.1 passes graceful and abrupt closes, a companion task through the broker, and six reacquire runs with notSubscribed probes after every release.

Codex review reports no findings at 162f7e8. Ready for another look if / when you have time.

@ahmetgemici332-netizen

ahmetgemici332-netizen commented Sep 3, 2026 via email

Copy link
Copy Markdown

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.

App-server broker retains thread subscriptions after task clients disconnect

3 participants