Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 49 additions & 20 deletions app/components/workspace-logs/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -140,21 +141,22 @@ 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;
}
this.openSocket(this.get('workspace').brokerUrl(this.get('entry')), false);
},

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');
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -225,6 +239,9 @@ export default Component.extend({
},

handleMessage(raw) {
if (this.connectionInactive()) {
return;
}
let frame;
try {
frame = JSON.parse(raw);
Expand Down Expand Up @@ -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() {
Expand Down
82 changes: 58 additions & 24 deletions app/components/workspace-terminal/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -157,24 +157,30 @@ 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));
});
},

applyBrokerStatusAction(action) {
if (this.connectionInactive()) {
return;
}
let workspace = this.get('workspace');
let entry = this.get('entry');

Expand All @@ -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');
Expand All @@ -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;
Expand All @@ -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;
}

Expand All @@ -286,6 +305,9 @@ export default Component.extend(ThrottledResize, {
},

handleMessage(raw) {
if (this.connectionInactive()) {
return;
}
let frame;
try {
frame = JSON.parse(raw);
Expand Down Expand Up @@ -402,17 +424,29 @@ 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');
this.get('workspace').updateSession(this.get('entry'), {status: 'error'});
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() {
Expand Down
Loading
Loading