From b9b841e65afe1d89a5b03ac767e9168bccd3c3ea Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Mon, 5 Oct 2026 10:21:13 +0800 Subject: [PATCH] fix: preserve ended workspace log and terminal session ownership --- COMPATIBILITY.md | 10 + README.md | 10 + app/components/workspace-logs/component.js | 69 +++-- .../workspace-terminal/component.js | 82 ++++-- app/mixins/workspace-connection-lifecycle.js | 29 +++ ...ass-replacement.node24-ignore-scripts.json | 4 +- docs/releases/web-console-1.6.177.md | 46 ++++ package-lock.json | 4 +- package.json | 2 +- scripts/check-modernization-blockers | 4 +- scripts/check-ui-console-workspace | 4 +- scripts/check-ui-critical-high-dependencies | 2 +- .../workspace-ended-lifecycle-test.js | 246 ++++++++++++++++++ 13 files changed, 459 insertions(+), 53 deletions(-) create mode 100644 app/mixins/workspace-connection-lifecycle.js create mode 100644 docs/releases/web-console-1.6.177.md create mode 100644 tests/unit/components/workspace-ended-lifecycle-test.js diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 5bf36282bd..7abfa628f6 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -4,6 +4,16 @@ Web Console preserves compatible API paths, schema and resource names, action na Visible branding, product-owned assets, icon identifiers, package metadata, and operator documentation use PastureStack. Historical identifiers remain only where they are server data or protocol contracts and must not be mechanically replaced. +`1.6.177` source treats an ended workspace entry as terminal across both logs +and terminal components. Late responses and queued socket/timer callbacks are +bound to the original entry, never an explicitly opened replacement. Existing +API payloads, broker authentication, session generation/mutex, authorization, +MFA and stored entry format are unchanged. Dependencies are unchanged; only +root release-version metadata and its reviewed baseline pins change. Seventeen +targeted component tests passed locally; formal CI, publication and deployed +acceptance remain pending. See the +[release note](docs/releases/web-console-1.6.177.md). + Published `1.6.176` preserves native `mut` setters by composing `action` with `fn (mut ...)` in the same 21 existing select bindings. The classic helper's event `target.value` conversion is unchanged. Model identities, selected-row diff --git a/README.md b/README.md index bd57a7ab6b..db99d8ee20 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status +`1.6.177` source prevents ended log and terminal workspace entries from +reconnecting after remount/reload or from late access-ticket, broker, socket +and timer callbacks. Both components share one lifecycle boundary; asynchronous +work remains bound to its original entry. Explicitly opening a new entry and +reconnecting a live entry remain supported. Seventeen targeted real-component +tests passed locally. Formal exact-source CI, numeric publication, Server +packaging and isolated browser acceptance are pending; this is not full-matrix +or production acceptance. See the +[release note](docs/releases/web-console-1.6.177.md). + Published `1.6.176` repairs native select bindings that passed a `mut` reference directly to the classic compatibility `action` helper. The helper receives the current value instead of a setter; wrap `mut` in native `fn` to retain the setter diff --git a/app/components/workspace-logs/component.js b/app/components/workspace-logs/component.js index 8c6b81d810..c3654b5622 100644 --- a/app/components/workspace-logs/component.js +++ b/app/components/workspace-logs/component.js @@ -3,6 +3,7 @@ import { next, later, cancel } from '@ember/runloop'; import { equal } from '@ember/object/computed'; import { service } from '@ember/service'; import Component from '@ember/component'; +import WorkspaceConnectionLifecycle from 'ui/mixins/workspace-connection-lifecycle'; import Util from 'ui/utils/util'; import { formatDateTime } from 'ui/utils/date-time'; @@ -53,7 +54,7 @@ function saveLogWrapPreference(value, storage = window.localStorage) { } } -export default Component.extend({ +export default Component.extend(WorkspaceConnectionLifecycle, { classNames: ['workspace-logs'], workspace: service('console-workspace'), intl: service(), @@ -140,14 +141,10 @@ export default Component.extend({ }, connect(create) { - if (this.get('userClosed') || this.isDestroyed || this.isDestroying) { + if (this.connectionInactive()) { return; } if (create) { - if (this.get('entry.status') === 'ended') { - this.set('status', 'ended'); - return; - } this.createBrokerSession(); return; } @@ -155,6 +152,11 @@ export default Component.extend({ }, createBrokerSession() { + if (this.connectionInactive()) { + return; + } + let entry = this.get('entry'); + let workspace = this.get('workspace'); let instance = this.get('instance'); if (!instance || !instance.hasAction('logs')) { this.set('status', 'error'); @@ -163,33 +165,41 @@ export default Component.extend({ this.set('createAttempted', true); this.set('status', 'initializing'); - this.get('workspace').updateSession(this.get('entry'), {status: 'initializing'}); - instance.doAction('logs', { + workspace.updateSession(entry, {status: 'initializing'}); + return instance.doAction('logs', { follow: true, lines: 500, }).then((access) => { - if (this.get('userClosed') || this.isDestroyed || this.isDestroying) { + if (this.connectionInactive(entry)) { return; } - return this.get('workspace').createBrokerSession(this.get('entry'), access); - }).then(() => { - if (this.get('userClosed') || this.isDestroyed || this.isDestroying) { + return workspace.createBrokerSession(entry, access); + }).then((response) => { + if (this.connectionInactive(entry)) { return; } - this.get('workspace').updateSession(this.get('entry'), { + if (response && response.status === 'ended') { + workspace.updateSession(entry, {brokerReady: false, status: 'ended'}); + return; + } + workspace.updateSession(entry, { brokerReady: true, status: 'connecting', }); - this.openSocket(this.get('workspace').brokerUrl(this.get('entry')), true); + this.openSocket(workspace.brokerUrl(entry), true); }).catch(() => { - if (!this.isDestroyed && !this.isDestroying) { + if (!this.connectionInactive(entry)) { this.set('status', 'error'); - this.get('workspace').updateSession(this.get('entry'), {status: 'error'}); + workspace.updateSession(entry, {status: 'error'}); } }); }, openSocket(url, creating) { + if (this.connectionInactive()) { + return; + } + let entry = this.get('entry'); let previous = this.get('socket'); if (previous) { previous.onclose = null; @@ -200,13 +210,17 @@ export default Component.extend({ status: creating ? 'initializing' : 'connecting', hasHello: false, }); - let protocols = this.get('workspace').brokerProtocols(this.get('entry')); + let protocols = this.get('workspace').brokerProtocols(entry); let socket = new WebSocket(url, protocols); this.set('socket', socket); - socket.onmessage = (message) => this.handleMessage(message.data); + socket.onmessage = (message) => { + if (this.get('socket') === socket && !this.connectionInactive(entry)) { + this.handleMessage(message.data); + } + }; socket.onclose = () => { - if (this.get('userClosed') || this.isDestroyed || this.isDestroying) { + if (this.get('socket') !== socket || this.connectionInactive(entry)) { return; } this.set('socket', null); @@ -225,6 +239,9 @@ export default Component.extend({ }, handleMessage(raw) { + if (this.connectionInactive()) { + return; + } let frame; try { frame = JSON.parse(raw); @@ -315,12 +332,24 @@ export default Component.extend({ scheduleReconnect() { this.cancelReconnect(); + if (this.connectionInactive()) { + return; + } + let entry = this.get('entry'); let attempt = this.incrementProperty('reconnectAttempts'); let delay = Math.min(10000, 500 * Math.pow(2, Math.min(attempt, 5))); - this._reconnectTimer = later(this, () => { + let timer = later(this, () => { + if (this._reconnectTimer !== timer) { + return; + } + this._reconnectTimer = null; + if (this.connectionInactive(entry)) { + return; + } this.set('createAttempted', false); this.connect(false); }, delay); + this._reconnectTimer = timer; }, cancelReconnect() { diff --git a/app/components/workspace-terminal/component.js b/app/components/workspace-terminal/component.js index ec52c4b981..3a56e46e0c 100644 --- a/app/components/workspace-terminal/component.js +++ b/app/components/workspace-terminal/component.js @@ -3,6 +3,7 @@ import { equal } from '@ember/object/computed'; import { service } from '@ember/service'; import Component from '@ember/component'; import ThrottledResize from 'ui/mixins/throttled-resize'; +import WorkspaceConnectionLifecycle from 'ui/mixins/workspace-connection-lifecycle'; import { DEFAULT_COMMAND } from 'ui/components/container-shell/component'; const Terminal = window.Terminal; @@ -21,9 +22,12 @@ function terminalCloseAction(options) { if (options.userClosed || options.destroyed) { return 'ignore'; } + if (options.entryStatus === 'ended') { + return 'ended'; + } if (!options.hasHello && !options.createAttempted) { - return options.entryStatus === 'ended' ? 'ended' : 'probe'; + return 'probe'; } return options.status === 'ended' ? 'none' : 'reconnect'; @@ -48,7 +52,7 @@ function terminalBrokerStatusAction(httpStatus, brokerStatus) { return 'connect'; } -export default Component.extend(ThrottledResize, { +export default Component.extend(ThrottledResize, WorkspaceConnectionLifecycle, { classNames: ['workspace-terminal'], workspace: service('console-workspace'), entry: null, @@ -140,15 +144,11 @@ export default Component.extend(ThrottledResize, { }, connect(create) { - if (this.get('userClosed') || this.isDestroyed || this.isDestroying) { + if (this.connectionInactive()) { return; } if (create) { - if (this.get('entry.status') === 'ended') { - this.set('status', 'ended'); - return; - } this.createBrokerSession(); return; } @@ -157,17 +157,20 @@ export default Component.extend(ThrottledResize, { }, probeBrokerSession() { + if (this.connectionInactive()) { + return; + } let workspace = this.get('workspace'); let entry = this.get('entry'); this.set('status', 'connecting'); - workspace.brokerStatus(entry).then((response) => { - if (this.get('userClosed') || this.isDestroyed || this.isDestroying) { + return workspace.brokerStatus(entry).then((response) => { + if (this.connectionInactive(entry)) { return; } this.applyBrokerStatusAction(terminalBrokerStatusAction(200, response && response.status)); }).catch((error) => { - if (this.get('userClosed') || this.isDestroyed || this.isDestroying) { + if (this.connectionInactive(entry)) { return; } this.applyBrokerStatusAction(terminalBrokerStatusAction(error && error.status, null)); @@ -175,6 +178,9 @@ export default Component.extend(ThrottledResize, { }, applyBrokerStatusAction(action) { + if (this.connectionInactive()) { + return; + } let workspace = this.get('workspace'); let entry = this.get('entry'); @@ -200,6 +206,11 @@ export default Component.extend(ThrottledResize, { }, createBrokerSession() { + if (this.connectionInactive()) { + return; + } + let entry = this.get('entry'); + let workspace = this.get('workspace'); let instance = this.get('instance'); if (!instance || !instance.hasAction('execute')) { this.set('status', 'error'); @@ -208,41 +219,45 @@ export default Component.extend(ThrottledResize, { this.set('createAttempted', true); this.set('status', 'initializing'); - this.get('workspace').updateSession(this.get('entry'), {status: 'initializing'}); + workspace.updateSession(entry, {status: 'initializing'}); let options = { attachStdin: true, attachStdout: true, tty: true, - command: this.get('entry.command') || DEFAULT_COMMAND, + command: entry.get('command') || DEFAULT_COMMAND, }; - instance.doAction('execute', options).then((access) => { - if (this.get('userClosed') || this.isDestroyed || this.isDestroying) { + return instance.doAction('execute', options).then((access) => { + if (this.connectionInactive(entry)) { return; } - return this.get('workspace').createBrokerSession(this.get('entry'), access); + return workspace.createBrokerSession(entry, access); }).then((response) => { - if (this.get('userClosed') || this.isDestroyed || this.isDestroying) { + if (this.connectionInactive(entry)) { return; } if (terminalBrokerStatusAction(200, response && response.status) === 'ended') { this.applyBrokerStatusAction('ended'); return; } - this.get('workspace').updateSession(this.get('entry'), { + workspace.updateSession(entry, { brokerReady: true, status: 'connecting', }); - this.openSocket(this.get('workspace').brokerUrl(this.get('entry')), true); + this.openSocket(workspace.brokerUrl(entry), true); }).catch(() => { - if (!this.isDestroyed && !this.isDestroying) { + if (!this.connectionInactive(entry)) { this.set('status', 'error'); - this.get('workspace').updateSession(this.get('entry'), {status: 'error'}); + workspace.updateSession(entry, {status: 'error'}); } }); }, openSocket(url, creating) { + if (this.connectionInactive()) { + return; + } + let entry = this.get('entry'); let previous = this.get('socket'); if (previous) { previous.onclose = null; @@ -254,13 +269,17 @@ export default Component.extend(ThrottledResize, { hasHello: false, }); - let protocols = this.get('workspace').brokerProtocols(this.get('entry')); + let protocols = this.get('workspace').brokerProtocols(entry); let socket = new WebSocket(url, protocols); this.set('socket', socket); - socket.onmessage = (message) => this.handleMessage(message.data); + socket.onmessage = (message) => { + if (this.get('socket') === socket && !this.connectionInactive(entry)) { + this.handleMessage(message.data); + } + }; socket.onclose = () => { - if (this.get('socket') !== socket) { + if (this.get('socket') !== socket || this.connectionInactive(entry)) { return; } @@ -286,6 +305,9 @@ export default Component.extend(ThrottledResize, { }, handleMessage(raw) { + if (this.connectionInactive()) { + return; + } let frame; try { frame = JSON.parse(raw); @@ -402,6 +424,10 @@ export default Component.extend(ThrottledResize, { scheduleReconnect() { this.cancelReconnect(); + if (this.connectionInactive()) { + return; + } + let entry = this.get('entry'); let attempt = this.incrementProperty('reconnectAttempts'); if (attempt > MAX_RECONNECT_ATTEMPTS) { this.set('status', 'error'); @@ -409,10 +435,18 @@ export default Component.extend(ThrottledResize, { return; } let delay = Math.min(10000, 500 * Math.pow(2, Math.min(attempt, 5))); - this._reconnectTimer = later(this, () => { + let timer = later(this, () => { + if (this._reconnectTimer !== timer) { + return; + } + this._reconnectTimer = null; + if (this.connectionInactive(entry)) { + return; + } this.set('createAttempted', false); this.connect(false); }, delay); + this._reconnectTimer = timer; }, cancelReconnect() { diff --git a/app/mixins/workspace-connection-lifecycle.js b/app/mixins/workspace-connection-lifecycle.js new file mode 100644 index 0000000000..dcd66c1366 --- /dev/null +++ b/app/mixins/workspace-connection-lifecycle.js @@ -0,0 +1,29 @@ +import Mixin from '@ember/object/mixin'; + +export default Mixin.create({ + connectionInactive(entry = this.get('entry')) { + if (this.get('userClosed') || this.isDestroyed || this.isDestroying || entry !== this.get('entry')) { + return true; + } + if (!entry || entry.get('status') !== 'ended') { + return false; + } + + this.cancelReconnect(); + this.set('status', 'ended'); + if (this.setTerminalInputEnabled) { + this.setTerminalInputEnabled(false); + } + let socket = this.get('socket'); + if (socket) { + socket.onmessage = socket.onclose = null; + this.set('socket', null); + socket.close(); + } + return true; + }, + + entryStatusChanged: function() { + this.connectionInactive(); + }.observes('entry.status'), +}); diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index 11f7add2ea..82eb97fd86 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.176", + "version": "1.6.177", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.176", + "version": "1.6.177", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.177.md b/docs/releases/web-console-1.6.177.md new file mode 100644 index 0000000000..ddb8fde7dc --- /dev/null +++ b/docs/releases/web-console-1.6.177.md @@ -0,0 +1,46 @@ +# Web Console 1.6.177 + +Status: source repair; formal exact-source CI, immutable publication, Server +packaging and isolated browser acceptance are pending. No production deployment +is authorized by this change. The complete permission/resource matrix remains +INCOMPLETE; historical HOLD results are not rewritten. + +## Root cause and repair + +An ended log entry could reopen a WebSocket on a non-creating reconnect. +A terminal entry could probe the missing broker and create another session. +An access ticket or broker response arriving after end could also restore a +connecting/error state. Already queued socket and timer callbacks did not retain +their original entry identity. + +Both components now use `workspace-connection-lifecycle` to stop inactive work, +cancel reconnects, close the owned socket and disable terminal input after end. +Ticket/broker continuations, socket callbacks and timers retain their original +entry. Socket/timer identity checks keep obsolete callbacks from touching a +replacement. End is not a permanent component shutdown: explicitly opening a +new entry and reconnecting a live session still work. + +Changed product files: + +- `app/mixins/workspace-connection-lifecycle.js`: shared ended/identity boundary. +- `app/components/workspace-logs/component.js`: adopt the boundary and retain + entry/socket/timer ownership; reject an already-ended create response. +- `app/components/workspace-terminal/component.js`: the same lifecycle boundary + across probe/create/connect and queued callbacks. +- `tests/unit/components/workspace-ended-lifecycle-test.js`: 17 targeted + real-component regressions using deferred responses and the native run loop. + +## Verification boundary + +Local Chrome 153 ran 17/17 targeted tests with zero failures, skips or todo. +The tests cover ended remount/reconnect, deferred ticket/broker success and +failure, ended broker response, queued frames/timers, old-entry replacement, +late terminal probes and the live/new-entry controls. Babel parsing and +`git diff --check` passed. No test JWT or broker secret is written to evidence. + +Dependency versions and graph, workspace service/persistence format, API +contracts, permission checks, generation/mutex and MFA are unchanged. Root +version metadata and existing executable/reviewed-baseline pins become 1.6.177. +Release commit, archive SHA256 and the Server image digest will be reported only +after publication and public readback. Retain existing configuration, named +volumes and immutable rollback images. diff --git a/package-lock.json b/package-lock.json index 11f7add2ea..82eb97fd86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.176", + "version": "1.6.177", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.176", + "version": "1.6.177", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index bcb5c9638a..fa0af56534 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.176", + "version": "1.6.177", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index c143c9680c..c7e84ecc97 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.176" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.176" +if [[ "$version" != "1.6.177" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.177" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index 5c9b782f89..8aca861389 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -13,11 +13,13 @@ required_files=( app/components/workspace-terminal/template.hbs app/components/workspace-logs/component.js app/components/workspace-logs/template.hbs + app/mixins/workspace-connection-lifecycle.js app/services/console-workspace.js app/utils/console-workspace.js app/styles/components/_console-workspace.scss tests/unit/components/workspace-terminal-test.js tests/unit/components/workspace-logs-test.js + tests/unit/components/workspace-ended-lifecycle-test.js tests/unit/services/console-workspace-test.js tests/unit/utils/console-workspace-test.js ) @@ -141,4 +143,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI fi printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \ - 1.6.176 browser-session broker-broadcast + 1.6.177 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 6eafe81910..2d6af6ee44 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -70,7 +70,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.176": +if package.get("version") != "1.6.177": fail(f"unexpected Web Console version: {package.get('version')}") if root.get("version") != package.get("version"): fail(f"lock root version differs: {root.get('version')}") diff --git a/tests/unit/components/workspace-ended-lifecycle-test.js b/tests/unit/components/workspace-ended-lifecycle-test.js new file mode 100644 index 0000000000..e2da9a8696 --- /dev/null +++ b/tests/unit/components/workspace-ended-lifecycle-test.js @@ -0,0 +1,246 @@ +import EmberObject from '@ember/object'; +import { run } from '@ember/runloop'; +import { settled } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import WorkspaceLogs from 'ui/components/workspace-logs/component'; +import WorkspaceTerminal, { terminalCloseAction } from 'ui/components/workspace-terminal/component'; +import inertRenderer from '../../helpers/inert-renderer'; +import { createOwned, destroyOwned } from '../../helpers/owned-subject'; + +module('Unit | Component | workspace ended lifecycle'); + +function deferred() { + let resolve, reject; + let promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return {promise, resolve, reject}; +} + +function subject(kind, options = {}) { + let calls = {tickets: 0, brokers: [], sockets: 0, probes: 0, rotations: 0, updates: []}; + let entry = EmberObject.create({status: options.status || 'new', brokerReady: options.brokerReady || false}); + let workspace = EmberObject.create({ + updateSession(target, values) { calls.updates.push(values); target.setProperties(values); }, + brokerUrl() { return 'mock-broker'; }, + brokerProtocols() { return []; }, + brokerStatus() { calls.probes++; return options.probe || Promise.resolve({status: 'connected'}); }, + createBrokerSession(target) { calls.brokers.push(target); return options.broker || Promise.resolve({status: 'connected'}); }, + rotateBrokerIdentity() { calls.rotations++; }, + }); + let instance = EmberObject.create({ + hasAction() { return true; }, + doAction() { calls.tickets++; return options.access ? options.access() : Promise.resolve({}); }, + }); + let component; + run(() => { + component = createOwned(kind === 'logs' ? WorkspaceLogs : WorkspaceTerminal, { + renderer: inertRenderer(), entry, workspace, instance, + intl: EmberObject.create({t(key) { return key; }}), + setupTerminal() {}, + openSocket() { calls.sockets++; }, + }, 'component'); + }); + return {component, entry, calls, end() { run(() => entry.set('status', 'ended')); }, + destroy() { destroyOwned(component); }}; +} + +for (let kind of ['logs', 'terminal']) { + test(`${kind}: remount and reconnect never transport an ended entry`, async function(assert) { + for (let brokerReady of [false, true]) { + let s = subject(kind, {status: 'ended', brokerReady}); + let ansi = window.rc16AnsiUp; + window.rc16AnsiUp = {AnsiUp: class {}}; + try { + run(() => s.component.didInsertElement()); + await settled(); + run(() => { + s.component.connect(false); + s.component.connect(true); + s.component.createBrokerSession(); + s.component.send('reconnect'); + if (kind === 'terminal') { + s.component.probeBrokerSession(); + for (let action of ['create', 'rotate', 'connect', 'error']) { + s.component.applyBrokerStatusAction(action); + } + } + }); + let originalSocket = window.WebSocket, socketConstructions = 0; + window.WebSocket = class { constructor() { socketConstructions++; } close() {} }; + try { + let Factory = kind === 'logs' ? WorkspaceLogs : WorkspaceTerminal; + run(() => Factory.prototype.openSocket.call(s.component, 'mock-broker', false)); + } finally { window.WebSocket = originalSocket; } + assert.equal(socketConstructions, 0, 'the real openSocket method also rejects the ended entry'); + assert.equal(s.calls.tickets + s.calls.brokers.length + s.calls.sockets + s.calls.probes + s.calls.rotations, 0); + assert.equal(s.component.get('status'), 'ended'); + assert.equal(s.entry.get('status'), 'ended'); + assert.deepEqual(s.calls.updates, [], 'the persisted ended entry is never rewritten'); + } finally { window.rc16AnsiUp = ansi; s.destroy(); } + } + }); + + test(`${kind}: late ticket success or failure cannot create a broker after end`, async function(assert) { + for (let rejected of [false, true]) { + let access = deferred(), s = subject(kind, {access: () => access.promise}); + try { + let request = run(() => s.component.createBrokerSession()); + assert.equal(s.calls.tickets, 1); + s.end(); + if (rejected) { access.reject(new Error('late ticket')); } else { access.resolve({}); } + await request; + assert.equal(s.calls.brokers.length + s.calls.sockets, 0); + assert.equal(s.entry.get('status'), 'ended'); + assert.equal(s.component.get('status'), 'ended'); + assert.equal(s.calls.updates.length, 1, 'only the original initializing update occurred'); + } finally { s.destroy(); } + } + }); + + test(`${kind}: late broker success or failure cannot reopen an ended entry`, async function(assert) { + for (let rejected of [false, true]) { + let broker = deferred(), s = subject(kind, {broker: broker.promise}); + try { + let request = run(() => s.component.createBrokerSession()); + await Promise.resolve(); + assert.equal(s.calls.brokers.length, 1); + s.end(); + if (rejected) { broker.reject(new Error('late broker')); } else { broker.resolve({status: 'connected'}); } + await request; + assert.equal(s.calls.sockets, 0); + assert.equal(s.entry.get('status'), 'ended'); + assert.equal(s.component.get('status'), 'ended'); + assert.equal(s.calls.updates.length, 1, 'no connecting or error update after end'); + } finally { s.destroy(); } + } + }); + + test(`${kind}: an ended broker create response never opens a socket`, async function(assert) { + let s = subject(kind, {broker: Promise.resolve({status: 'ended'})}); + try { + await run(() => s.component.createBrokerSession()); + assert.equal(s.calls.tickets, 1); + assert.equal(s.calls.brokers.length, 1); + assert.equal(s.calls.sockets, 0); + assert.equal(s.entry.get('status'), 'ended'); + assert.equal(s.component.get('status'), 'ended'); + } finally { s.destroy(); } + }); + + test(`${kind}: end cancels reconnect and ignores already queued socket frames`, async function(assert) { + let s = subject(kind, {status: 'connected', brokerReady: true}); + let closes = 0; + try { + run(() => { + if (kind === 'terminal') { + s.component.set('term', {options: {disableStdin: false}}); + } + s.component.set('socket', {onmessage() {}, onclose() {}, close() { closes++; }}); + s.component.scheduleReconnect(); + }); + assert.ok(s.component._reconnectTimer); + s.end(); + assert.strictEqual(s.component._reconnectTimer, null); + assert.strictEqual(s.component.get('socket'), null); + assert.equal(closes, 1); + if (kind === 'terminal') { + assert.ok(s.component.get('term.options.disableStdin'), 'ending disables real terminal input'); + } + run(() => { + s.component.scheduleReconnect(); + for (let type of ['hello', 'output', 'status', 'error']) { + s.component.handleMessage(JSON.stringify({type, status: 'connected', data: ''})); + } + }); + await settled(); + assert.strictEqual(s.component._reconnectTimer, null); + assert.equal(s.component.get('reconnectAttempts'), 1, 'ending does not schedule another attempt'); + assert.equal(s.component.get('status'), 'ended'); + assert.equal(s.entry.get('status'), 'ended'); + assert.equal(s.calls.sockets + s.calls.probes + s.calls.tickets, 0); + assert.deepEqual(s.calls.updates, []); + } finally { s.destroy(); } + }); + + test(`${kind}: live reconnect and an explicitly new entry still work`, async function(assert) { + let s = subject(kind, {status: 'connected', brokerReady: true}); + try { + await run(() => s.component.connect(false)); + await settled(); + assert.equal(s.calls.sockets, 1, 'existing live session reconnects'); + s.end(); + let fresh = EmberObject.create({status: 'new', brokerReady: false}); + run(() => s.component.set('entry', fresh)); + await run(() => s.component.createBrokerSession()); + assert.equal(s.calls.tickets, 1); + assert.deepEqual(s.calls.brokers, [fresh], 'new session has its own entry identity'); + assert.equal(s.calls.sockets, 2); + assert.equal(s.entry.get('status'), 'ended', 'the old session stays ended'); + assert.equal(fresh.get('status'), 'connecting'); + assert.notOk(s.component.get('userClosed'), 'end is not a permanent component shutdown'); + } finally { s.destroy(); } + }); + + test(`${kind}: an old pending ticket cannot act on an explicitly new entry`, async function(assert) { + let access = deferred(), count = 0; + let s = subject(kind, {access: () => ++count === 1 ? access.promise : Promise.resolve({})}); + try { + let oldRequest = run(() => s.component.createBrokerSession()); + s.end(); + let fresh = EmberObject.create({status: 'new', brokerReady: false}); + run(() => s.component.set('entry', fresh)); + await run(() => s.component.createBrokerSession()); + access.resolve({}); + await oldRequest; + assert.deepEqual(s.calls.brokers, [fresh]); + assert.equal(s.calls.sockets, 1); + assert.equal(s.entry.get('status'), 'ended'); + assert.equal(fresh.get('status'), 'connecting'); + } finally { s.destroy(); } + }); + + test(`${kind}: queued socket and timer belong to their original entry`, async function(assert) { + let s = subject(kind, {status: 'connected', brokerReady: true}); + let originalSocket = window.WebSocket, socket; + window.WebSocket = class { constructor() { socket = this; } close() {} }; + try { + let Factory = kind === 'logs' ? WorkspaceLogs : WorkspaceTerminal; + run(() => { + Factory.prototype.openSocket.call(s.component, 'mock-broker', false); + s.component.scheduleReconnect(); + }); + assert.ok(s.component._reconnectTimer, 'the original entry has a real scheduled timer'); + let fresh = EmberObject.create({status: 'connected', brokerReady: true}); + run(() => { + s.component.setProperties({entry: fresh, status: 'connected', createAttempted: true}); + socket.onmessage({data: JSON.stringify({type: 'hello', status: 'connected'})}); + socket.onclose(); + }); + await settled(); + assert.equal(s.calls.sockets + s.calls.probes + s.calls.tickets + s.calls.rotations, 0); + assert.deepEqual(s.calls.updates, [], 'old socket callbacks never rewrite the new entry'); + assert.equal(fresh.get('status'), 'connected'); + assert.equal(s.component.get('status'), 'connected'); + assert.ok(s.component.get('createAttempted'), 'the old timer never clears the new connection state'); + assert.equal(s.component.get('reconnectAttempts'), 1); + assert.strictEqual(s.component._reconnectTimer, null); + } finally { window.WebSocket = originalSocket; s.destroy(); } + }); +} + +test('terminal: a pending probe cannot reconnect, rotate or create after end', async function(assert) { + for (let http of [200, 404, 403, 409, 502]) { + let probe = deferred(), s = subject('terminal', {status: 'connected', probe: probe.promise}); + try { + let request = run(() => s.component.probeBrokerSession()); + s.end(); + if (http === 200) { probe.resolve({status: 'connected'}); } else { probe.reject({status: http}); } + await request; + assert.equal(s.calls.probes, 1); + assert.equal(s.calls.tickets + s.calls.brokers.length + s.calls.sockets + s.calls.rotations, 0, `late ${http} is inert`); + assert.equal(s.entry.get('status'), 'ended'); + assert.equal(s.component.get('status'), 'ended'); + assert.deepEqual(s.calls.updates, []); + } finally { s.destroy(); } + } + assert.equal(terminalCloseAction({entryStatus: 'ended', hasHello: true, createAttempted: true, status: 'connected'}), 'ended'); +});