mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) add GVISOR_LIMIT_MEMORY to cap memory available in sandbox
Summary: This allows limiting the memory available to documents in the sandbox when gvisor is used. If memory limit is exceeded, we offer to open doc in recovery mode. Recovery mode is tweaked to open docs with tables in "ondemand" mode, which will generally take less memory and allow for deleting rows. The limit is on the size of the virtual address space available to the sandbox (`RLIMIT_AS`), which in practice appears to function as one would want, and is the only practical option. There is a documented `RLIMIT_RSS` limit to `specifies the limit (in bytes) of the process's resident set (the number of virtual pages resident in RAM)` but this is no longer enforced by the kernel (neither the host nor gvisor). When the sandbox runs out of memory, there are many ways it can fail. This diff catches all the ones I saw, but there could be more. Test Plan: added tests Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3398
This commit is contained in:
@@ -110,7 +110,7 @@ import {Events as BackboneEvents} from 'backbone';
|
||||
* @property {Number} data - An array of unread invites (see app/common/sharing).
|
||||
*/
|
||||
|
||||
const ValidEvent = StringUnion('docListAction', 'docUserAction', 'docShutdown',
|
||||
const ValidEvent = StringUnion('docListAction', 'docUserAction', 'docShutdown', 'docError',
|
||||
'clientConnect', 'clientLogout',
|
||||
'profileFetch', 'userSettings', 'receiveInvites');
|
||||
type ValidEvent = typeof ValidEvent.type;
|
||||
@@ -213,8 +213,10 @@ export class Comm extends dispose.Disposable implements GristServerAPI, DocListA
|
||||
private _connections: Map<string|null, GristWSConnection> = new Map();
|
||||
private _collectedUserActions: UserAction[] | null;
|
||||
private _singleWorkerMode: boolean = getInitialDocAssignment() === null; // is this classic Grist?
|
||||
private _reportError?: (err: Error) => void; // optional callback for errors
|
||||
|
||||
public create() {
|
||||
public create(reportError?: (err: Error) => void) {
|
||||
this._reportError = reportError;
|
||||
this.autoDisposeCallback(() => {
|
||||
for (const connection of this._connections.values()) { connection.dispose(); }
|
||||
this._connections.clear();
|
||||
@@ -469,6 +471,7 @@ export class Comm extends dispose.Disposable implements GristServerAPI, DocListA
|
||||
err.shouldFork = message.shouldFork;
|
||||
console.log(`Comm response #${reqId} ${r.methodName} ERROR:${code} ${message.error}`
|
||||
+ (message.shouldFork ? ` (should fork)` : ''));
|
||||
this._reportError?.(err);
|
||||
r.reject(err);
|
||||
} else {
|
||||
console.log(`Comm response #${reqId} ${r.methodName} OK`);
|
||||
|
||||
@@ -72,6 +72,10 @@ export interface DocPageModel {
|
||||
updateCurrentDoc(urlId: string, openMode: OpenDocMode): Promise<Document>;
|
||||
refreshCurrentDoc(doc: DocInfo): Promise<Document>;
|
||||
updateCurrentDocUsage(docUsage: FilteredDocUsageSummary): void;
|
||||
// Offer to open document in recovery mode, if user is owner, and report
|
||||
// the error that prompted the offer. If user is not owner, just flag that
|
||||
// document needs attention of an owner.
|
||||
offerRecovery(err: Error): void;
|
||||
}
|
||||
|
||||
export interface ImportSource {
|
||||
@@ -204,6 +208,28 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
return urlState().pushUrl(nextState, {avoidReload: true, ...options});
|
||||
}
|
||||
|
||||
public offerRecovery(err: Error) {
|
||||
const isDenied = (err as any).code === 'ACL_DENY';
|
||||
const isOwner = this.currentDoc.get()?.access === 'owners';
|
||||
confirmModal(
|
||||
"Error accessing document",
|
||||
"Reload",
|
||||
async () => window.location.reload(true),
|
||||
isOwner ? `You can try reloading the document, or using recovery mode. ` +
|
||||
`Recovery mode opens the document to be fully accessible to owners, and ` +
|
||||
`inaccessible to others. It also disables formulas. ` +
|
||||
`[${err.message}]` :
|
||||
isDenied ? `Sorry, access to this document has been denied. [${err.message}]` :
|
||||
`Document owners can attempt to recover the document. [${err.message}]`,
|
||||
{ hideCancel: true,
|
||||
extraButtons: (isOwner && !isDenied) ? bigBasicButton('Enter recovery mode', dom.on('click', async () => {
|
||||
await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
|
||||
window.location.reload(true);
|
||||
}), testId('modal-recovery-mode')) : null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private _onOpenError(err: Error) {
|
||||
if (err instanceof CancelledError) {
|
||||
// This means that we started loading a new doc before the previous one finished loading.
|
||||
@@ -213,22 +239,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
// Expected errors (e.g. Access Denied) produce a separate error page. For unexpected errors,
|
||||
// show a modal, and include a toast for the sake of the "Report error" link.
|
||||
reportError(err);
|
||||
const isOwner = this.currentDoc.get()?.access === 'owners';
|
||||
confirmModal(
|
||||
"Error opening document",
|
||||
"Reload",
|
||||
async () => window.location.reload(true),
|
||||
isOwner ? `You can try reloading the document, or using recovery mode. ` +
|
||||
`Recovery mode opens the document to be fully accessible to owners, and ` +
|
||||
`inaccessible to others. ` +
|
||||
`[${err.message}]` : err.message,
|
||||
{ hideCancel: true,
|
||||
extraButtons: isOwner ? bigBasicButton('Enter recovery mode', dom.on('click', async () => {
|
||||
await this._api.getDocAPI(this.currentDocId.get()!).recover(true);
|
||||
window.location.reload(true);
|
||||
}), testId('modal-recovery-mode')) : null,
|
||||
},
|
||||
);
|
||||
this.offerRecovery(err);
|
||||
}
|
||||
|
||||
private async _openDoc(flow: AsyncFlow, urlId: string, urlOpenMode: OpenDocMode | undefined,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {isDesktop} from 'app/client/lib/browserInfo';
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import * as koUtil from 'app/client/lib/koUtil';
|
||||
import {reportError, TopAppModel, TopAppModelImpl} from 'app/client/models/AppModel';
|
||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {setUpErrorHandling} from 'app/client/models/errors';
|
||||
import {createAppUI} from 'app/client/ui/AppUI';
|
||||
import {addViewportTag} from 'app/client/ui/viewport';
|
||||
@@ -31,7 +32,7 @@ export class App extends DisposableWithEvents {
|
||||
// Used by #newui code to avoid a dependency on commands.js, and by tests to issue commands.
|
||||
public allCommands = commands.allCommands;
|
||||
|
||||
public comm = this.autoDispose(Comm.create());
|
||||
public comm = this.autoDispose(Comm.create(this._checkError.bind(this)));
|
||||
public clientScope: ClientScope;
|
||||
public features: ko.Computed<ISupportedFeatures>;
|
||||
public topAppModel: TopAppModel; // Exposed because used by test/nbrowser/gristUtils.
|
||||
@@ -42,6 +43,9 @@ export class App extends DisposableWithEvents {
|
||||
// we can choose to refresh the client also.
|
||||
private _serverVersion: string|null = null;
|
||||
|
||||
// Track the most recently created DocPageModel, for some error handling.
|
||||
private _mostRecentDocPageModel?: DocPageModel;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -154,6 +158,10 @@ export class App extends DisposableWithEvents {
|
||||
setTimeout(() => this.reloadPane(), 0);
|
||||
});
|
||||
|
||||
this.listenTo(this.comm, 'docError', (msg) => {
|
||||
this._checkError(new Error(msg.data.message));
|
||||
});
|
||||
|
||||
// When the document is unloaded, dispose the app, allowing it to do any needed
|
||||
// cleanup (e.g. Document on disposal triggers closeDoc message to the server). It needs to be
|
||||
// in 'beforeunload' rather than 'unload', since websocket is closed by the time of 'unload'.
|
||||
@@ -202,6 +210,10 @@ export class App extends DisposableWithEvents {
|
||||
return true;
|
||||
}
|
||||
|
||||
public setDocPageModel(pageModel: DocPageModel) {
|
||||
this._mostRecentDocPageModel = pageModel;
|
||||
}
|
||||
|
||||
// Get the user profile for testing purposes
|
||||
public async testGetProfile(): Promise<any> {
|
||||
const resp = await fetchFromHome('/api/profile/user', {credentials: 'include'});
|
||||
@@ -211,4 +223,16 @@ export class App extends DisposableWithEvents {
|
||||
public testNumPendingApiRequests(): number {
|
||||
return BaseAPI.numPendingRequests();
|
||||
}
|
||||
|
||||
private _checkError(err: Error) {
|
||||
const message = String(err);
|
||||
// Take special action on any error that suggests a memory problem.
|
||||
if (message.match(/MemoryError|unmarshallable object/)) {
|
||||
if (err.message.length > 30) {
|
||||
// TLDR
|
||||
err.message = 'Memory Error';
|
||||
}
|
||||
this._mostRecentDocPageModel?.offerRecovery(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App)
|
||||
// To simplify manual inspection in the common case, keep the most recently created
|
||||
// DocPageModel available as a global variable.
|
||||
(window as any).gristDocPageModel = pageModel;
|
||||
appObj.setDocPageModel(pageModel);
|
||||
const leftPanelOpen = createSessionObs<boolean>(owner, "leftPanelOpen", true, isBoolean);
|
||||
const rightPanelOpen = createSessionObs<boolean>(owner, "rightPanelOpen", false, isBoolean);
|
||||
const leftPanelWidth = createSessionObs<number>(owner, "leftPanelWidth", 240, isNumber);
|
||||
|
||||
Reference in New Issue
Block a user