Repository navigation
fix(cli): use session channel when closing ACP sessions #4522
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,8 @@ import type { | |
| import { createDaemonStatusProvider } from './daemonStatusProvider.js'; | ||
| import { | ||
| createHttpAcpBridge, | ||
| detachSessionIdFromEntryChannel, | ||
| findChannelInfoForEntry, | ||
| InvalidClientIdError, | ||
| InvalidPermissionOptionError, | ||
| InvalidSessionMetadataError, | ||
|
|
@@ -299,6 +301,56 @@ function makeChannel(opts: FakeAgentOpts = {}): ChannelHandle { | |
| } | ||
|
|
||
| describe('createHttpAcpBridge', () => { | ||
| it('selects a session entry channel when the current attach channel has changed', () => { | ||
| // Model the channel-overlap window directly: old channel A still has | ||
| // an entry, while new channel B is the current attach target. | ||
| const channelA = { name: 'A' }; | ||
| const channelB = { name: 'B' }; | ||
| const infoA = { channel: channelA, label: 'old-dying-channel' }; | ||
| const infoB = { channel: channelB, label: 'fresh-attach-channel' }; | ||
|
|
||
| expect( | ||
| findChannelInfoForEntry(infoB, [infoA, infoB], { | ||
| channel: channelA, | ||
| }), | ||
| ).toBe(infoA); | ||
| expect( | ||
| findChannelInfoForEntry(infoB, [infoB], { | ||
| channel: channelA, | ||
| }), | ||
| ).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('detaches a session from its entry channel during channel overlap', () => { | ||
| // Model the close/kill overlap window directly: old channel A still owns | ||
| // the session entry while fresh channel B is the current attach target. | ||
| // The detach helper used by closeSession/killSession must decrement A, | ||
| // not the current B channel. | ||
| const channelA = { name: 'A' }; | ||
| const channelB = { name: 'B' }; | ||
| const infoA = { | ||
| channel: channelA, | ||
| sessionIds: new Set(['session-a']), | ||
| label: 'old-dying-channel', | ||
| }; | ||
| const infoB = { | ||
| channel: channelB, | ||
| sessionIds: new Set(['session-b']), | ||
| label: 'fresh-attach-channel', | ||
| }; | ||
|
|
||
| const selected = detachSessionIdFromEntryChannel( | ||
| infoB, | ||
| [infoA, infoB], | ||
| { channel: channelA }, | ||
| 'session-a', | ||
| ); | ||
|
|
||
| expect(selected).toBe(infoA); | ||
| expect(infoA.sessionIds.has('session-a')).toBe(false); | ||
| expect(Array.from(infoB.sessionIds)).toEqual(['session-b']); | ||
| }); | ||
|
|
||
| it('accepts a valid BridgeOptions.eventRingSize at construction time', () => { | ||
| // Smoke: positive finite integers are accepted; the underlying | ||
| // EventBus ring-size threading is exercised end-to-end in | ||
|
|
@@ -5840,6 +5892,37 @@ describe('createHttpAcpBridge', () => { | |
| await bridge.shutdown(); | ||
| }); | ||
|
|
||
| it('keeps a shared ACP channel alive while closing one of several sessions', async () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This test validates the ref-count contract (channel survives partial close) but uses a single shared channel where Consider adding an overlap scenario: spawn session A on channel X, force channel X into — qwen3.7-max via Qwen Code /review
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I kept this covered at the selector/unit boundary rather than adding a synthetic integration hook. In the current public bridge state machine, a session entry from channel A is not reachable for
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handled in 0df4f66. I extracted the entry-channel detach path used by both |
||
| const handles: ChannelHandle[] = []; | ||
| const factory: ChannelFactory = async () => { | ||
| const h = makeChannel({ sessionIdPrefix: `s${handles.length}` }); | ||
| handles.push(h); | ||
| return h.channel; | ||
| }; | ||
| const bridge = makeBridge({ | ||
| channelFactory: factory, | ||
| sessionScope: 'thread', | ||
| }); | ||
| const a = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); | ||
| const b = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); | ||
| const c = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); | ||
| expect(handles).toHaveLength(1); | ||
|
|
||
| await bridge.closeSession(b.sessionId); | ||
| expect(bridge.sessionCount).toBe(2); | ||
| expect(handles[0]?.killed).toBe(false); | ||
|
|
||
| await bridge.closeSession(a.sessionId); | ||
| expect(bridge.sessionCount).toBe(1); | ||
| expect(handles[0]?.killed).toBe(false); | ||
|
|
||
| await bridge.closeSession(c.sessionId); | ||
| expect(bridge.sessionCount).toBe(0); | ||
| expect(handles[0]?.killed).toBe(true); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This integration test validates single-channel ref-counting (3 sessions on one channel, close sequentially) — but Consider adding an integration test that goes through the — qwen3.7-max via Qwen Code /review
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I checked this against the current bridge state. The public |
||
|
|
||
| await bridge.shutdown(); | ||
| }); | ||
|
|
||
| it('throws SessionNotFoundError for unknown session', async () => { | ||
| const bridge = makeBridge(); | ||
| await expect(bridge.closeSession('nonexistent')).rejects.toThrow( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] The new test validates the "shared channel stays alive while closing one of several sessions" invariant, which is good regression coverage — but it does not exercise the specific bug this PR fixes.
All 3 sessions are spawned on a single channel (
expect(handles).toHaveLength(1)), so the module-scopedchannelInfoalways points to the correct (only) channel throughout the test. ThechannelInfoForEntryfast path (channelInfo?.channel === entry.channel) always matches. The pre-fix code (const ci = channelInfo) would also pass this test.The actual bug manifests only during a channel-overlap window: channel A is dying (SIGTERM in flight), channel B is freshly spawned (
channelInfo = B), thencloseSessionis called on a session that still belongs to A. A regression test for this scenario would:isDying(e.g., via slow-kill factory).channelInfo = B).closeSessionon a session belonging to A.sessionIdsdecremented (not B's) and B's channel is not killed.Without this, a future revert to the buggy code goes undetected.
— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added in e1bb5ef.
I added a focused regression for the overlap selector itself: it directly models old channel A still owning the session entry while fresh channel B is the current attach target, then asserts the lookup returns A and not B. I kept the existing closeSession shared-channel coverage as the lifecycle-level companion test.
Validation run:
I also re-ran the full
httpAcpBridge.test.tsfile: 162/163 passed; the only failure is the pre-existing Windows symlink permission failure atfsp.symlink(...)inwriteTextFile preserves symlinks (BX8Yw).