Skip to content
This repository was archived by the owner on Jul 30, 2025. It is now read-only.

Commit 268d3b4

Browse files
starpitk8s-ci-robot
authored andcommitted
fix(plugins/plugin-bash-like): new tab in browser-based clients may result in multiple new sessions
part of #7465
1 parent acddea5 commit 268d3b4

File tree

6 files changed

+83
-42
lines changed

6 files changed

+83
-42
lines changed

plugins/plugin-bash-like/src/pty/client.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
import Options from './options'
3939
import ChannelId from './channel-id'
4040
import { cleanupTerminalAfterTermination } from './util'
41-
import { getChannelForTab, invalidateSession } from './session'
41+
import { getChannelForTab, setChannelForTab, invalidateSession } from './session'
4242
import { Channel, InProcessChannel, WebViewChannelRendererSide } from './channel'
4343

4444
const debug = Debug('plugins/bash-like/pty/client')
@@ -770,10 +770,12 @@ const getOrCreateChannel = async (
770770

771771
const cachedws = getChannelForTab(tab)
772772

773-
if (!cachedws || cachedws.readyState === WebSocket.CLOSING || cachedws.readyState === WebSocket.CLOSED) {
774-
// allocating new channel
775-
const ws = await channelFactory(tab)
776-
tab['ws'] = ws
773+
if (!cachedws) {
774+
// Then we need to allocate a new channel. BE CAREFUL: avoid races
775+
// here by ensure that any `await` comes *after* grabbing a
776+
// reference to the channelFactory promise. see
777+
// https://github.com/kubernetes-sigs/kui/issues/7465
778+
const ws = await setChannelForTab(tab, channelFactory(tab))
777779

778780
// when the websocket is ready, handle any queued input; only then
779781
// do we focus the terminal (till then, the CLI module will handle
@@ -784,17 +786,15 @@ const getOrCreateChannel = async (
784786
const onClose = function(evt) {
785787
debug('channel has closed', evt.target, uuid)
786788
ws.removeEventListener('close', onClose)
787-
if (!tab.state.closed) {
788-
debug('attempting to reestablish connection, because the tab is still open')
789-
invalidateSession(tab)
790-
}
789+
debug('attempting to reestablish connection, because the tab is still open')
790+
invalidateSession(tab)
791791
}
792792
ws.on('close', onClose)
793793

794794
return ws
795795
} else {
796796
// reusing existing websocket
797-
doExec(cachedws)
797+
doExec(await cachedws)
798798
if (terminal) {
799799
focus(terminal)
800800
}

plugins/plugin-bash-like/src/pty/server.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ export const onConnection = (exitNow: ExitHandler, uid?: number, gid?: number) =
294294
const shell = msg.uuid && (await shells[msg.uuid])
295295
if (shell) {
296296
shell.kill(msg.signal || 'SIGHUP')
297-
return exitNow(msg.exitCode || 0)
297+
shells[msg.uuid] = undefined
298298
}
299299

300300
const job = msg.uuid && jobs[msg.uuid]
@@ -307,6 +307,18 @@ export const onConnection = (exitNow: ExitHandler, uid?: number, gid?: number) =
307307
}
308308

309309
case 'exit':
310+
for (const uuid in shells) {
311+
if (shells[uuid]) {
312+
;(await shells[uuid]).kill('SIGHUP')
313+
shells[uuid] = undefined
314+
}
315+
}
316+
for (const uuid in jobs) {
317+
if (jobs[uuid]) {
318+
jobs[uuid].abort()
319+
jobs[uuid] = undefined
320+
}
321+
}
310322
return exitNow(msg.exitCode || 0)
311323

312324
case 'request': {
@@ -565,9 +577,10 @@ export default (commandTree: Registrar) => {
565577
// eslint-disable-next-line no-async-promise-executor
566578
new Promise(async (resolve, reject) => {
567579
try {
568-
await new StdioChannelKuiSide().init(() => {
580+
await new StdioChannelKuiSide().init((exitCode: number) => {
569581
debug('done with stdiochannel')
570-
resolve(true)
582+
process.exit(exitCode)
583+
// resolve(true)
571584
})
572585
} catch (err) {
573586
reject(err)

plugins/plugin-bash-like/src/pty/session.ts

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,34 @@ const debug = Debug('plugins/bash-like/pty/session')
3030
* Return the cached websocket for the given tab
3131
*
3232
*/
33-
export function getChannelForTab(tab: Tab): Channel {
34-
return tab['ws'] as Channel
33+
let _singleChannel: Promise<Channel> // share session across tabs see https://github.com/IBM/kui/issues/6453
34+
let _exiting = false
35+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
36+
export function getChannelForTab(tab: Tab): Promise<Channel> {
37+
if (_exiting) {
38+
// prevent any stagglers re-establishing a channel
39+
throw new Error('Exiting')
40+
} else {
41+
return _singleChannel
42+
}
43+
}
44+
45+
/**
46+
* Set it
47+
*
48+
*/
49+
export function setChannelForTab(tab: Tab, channel: Promise<Channel>) {
50+
_singleChannel = channel
51+
52+
// listen for the end of the world
53+
channel.then(chan => {
54+
window.addEventListener('beforeunload', () => {
55+
_exiting = true // prevent any stagglers re-establishing a channel
56+
chan.close()
57+
})
58+
})
59+
60+
return _singleChannel
3561
}
3662

3763
/**
@@ -80,6 +106,7 @@ export function pollUntilOnline(tab: Tab) {
80106
if (err.code !== 503) {
81107
// don't bother complaining too much about connection refused
82108
console.error('error establishing session', err.code, err.statusCode, err)
109+
_singleChannel = undefined
83110
}
84111
setTimeout(() => once(iter + 1), iter < 10 ? 2000 : iter < 100 ? 4000 : 10000)
85112
return strings('Could not establish a new session')
@@ -95,7 +122,6 @@ export function pollUntilOnline(tab: Tab) {
95122
* given tab
96123
*
97124
*/
98-
let _singleChannel: Promise<Channel> // share session across tabs see https://github.com/IBM/kui/issues/6453
99125
async function newSessionForTab(tab: Tab) {
100126
const thisSession =
101127
_singleChannel ||
@@ -104,15 +130,12 @@ async function newSessionForTab(tab: Tab) {
104130
await pollUntilOnline(tab)
105131
tab.classList.add('kui--session-init-done')
106132

107-
resolve(getChannelForTab(tab))
133+
resolve(await getChannelForTab(tab))
108134
} catch (err) {
109135
reject(err)
110136
}
111137
})
112138

113-
if (!_singleChannel) {
114-
_singleChannel = thisSession
115-
}
116139
tab['_kui_session'] = thisSession
117140

118141
await thisSession
@@ -138,24 +161,6 @@ export async function init(registrar: PreloadRegistrar) {
138161

139162
if (inBrowser() && (proxyServer as { enabled?: boolean }).enabled !== false) {
140163
debug('initializing pty sessions')
141-
142-
const { eventBus } = await import('@kui-shell/core')
143-
144164
registrar.registerSessionInitializer(newSessionForTab)
145-
146-
// listen for closed tabs
147-
eventBus.on('/tab/close', async (tab: Tab) => {
148-
try {
149-
debug('closing session for tab')
150-
const channel = getChannelForTab(tab)
151-
if (channel) {
152-
// the user may have asked to close the tab before we have
153-
// finished initializing the channel
154-
channel.close()
155-
}
156-
} catch (err) {
157-
console.error('error terminating session for closed tab', err)
158-
}
159-
})
160165
}
161166
}

plugins/plugin-bash-like/src/pty/stdio-channel.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ export class StdioChannelWebsocketSide extends EventEmitter implements Channel {
6363
ws.on('message', (data: string) => {
6464
debugW('forwarding message downstream')
6565
try {
66-
child.stdin.write(`${data}${MARKER}`)
66+
if (!child.stdin.destroyed) {
67+
child.stdin.write(`${data}${MARKER}`)
68+
}
6769
} catch (err) {
6870
console.error('error in child write', err)
6971
}
@@ -74,7 +76,15 @@ export class StdioChannelWebsocketSide extends EventEmitter implements Channel {
7476

7577
ws.on('close', () => {
7678
debugW('killing child process, because client connection is dead')
77-
child.kill()
79+
try {
80+
if (!child.stdin.destroyed) {
81+
const data = JSON.stringify({ type: 'exit' })
82+
child.stdin.write(`${data}${MARKER}`)
83+
}
84+
} catch (err) {
85+
console.error('error in child write', err)
86+
}
87+
// no: child.kill() <-- instead, send an exit event, to allow subprocess to clean up
7888
})
7989
})
8090

plugins/plugin-bash-like/src/pty/websocket-channel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class WebSocketChannel implements Channel {
4343
close() {
4444
try {
4545
debug('closing websocket channel')
46-
this.ws.close()
46+
this.ws.send(JSON.stringify({ type: 'exit' }))
4747
} catch (err) {
4848
console.error('Error in WebSocketChannel.close', err)
4949
}

plugins/plugin-client-common/src/components/Views/Terminal/ScrollableTerminal.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,11 @@ export default class ScrollableTerminal extends React.PureComponent<Props, State
915915
eventChannelUnsafe.off(`/terminal/clear/${state.uuid}`, clear)
916916
})
917917

918+
// terminate watchable jobs when the window is closed
919+
const termAll = this.terminateAllWatchables.bind(this)
920+
window.addEventListener('beforeunload', termAll)
921+
this.cleaners.push(() => window.removeEventListener('beforeunload', termAll))
922+
918923
return state
919924
}
920925

@@ -995,6 +1000,7 @@ export default class ScrollableTerminal extends React.PureComponent<Props, State
9951000

9961001
public componentWillUnmount() {
9971002
this.uninitEvents()
1003+
this.terminateAllWatchables()
9981004
}
9991005

10001006
/**
@@ -1018,7 +1024,7 @@ export default class ScrollableTerminal extends React.PureComponent<Props, State
10181024
return availableSplitIdx
10191025
}
10201026

1021-
// If the block has watchable response, abort the job
1027+
/** If the block has watchable response, abort the job */
10221028
private removeWatchableBlock(block: BlockModel) {
10231029
if (isOk(block)) {
10241030
if (isWatchable(block.response)) {
@@ -1029,6 +1035,13 @@ export default class ScrollableTerminal extends React.PureComponent<Props, State
10291035
}
10301036
}
10311037

1038+
/** As per `removeWatchableBlock`, but for all blocks */
1039+
private terminateAllWatchables() {
1040+
this.state.splits.forEach(scrollback => {
1041+
scrollback.blocks.forEach(_ => this.removeWatchableBlock(_))
1042+
})
1043+
}
1044+
10321045
/**
10331046
* Remove the given split (identified by `sbuuid`) from the state.
10341047
*

0 commit comments

Comments
 (0)