Skip to content

Commit

Permalink
Add checks and guards related to memory allocation in web exports
Browse files Browse the repository at this point in the history
This is mainly relevant for Safari which has strict
budget for each page/worker. This is the main
reason why we cannot support it. Still, this may be
fixed by Apple at some point, and with these
changes we will be ready.

This also adds an early check for memory allocation
compatibility, so we can even warn the user ahead
of loading.
  • Loading branch information
YuriSizov committed Dec 27, 2024
1 parent 18a163d commit 5a8a2a4
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 29 deletions.
9 changes: 5 additions & 4 deletions dist/web-shell.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<meta property="og:image:height" content="630">

$GODOT_HEAD_INCLUDE
<link href="styles.css" rel="stylesheet">
<link href="styles.css?2" rel="stylesheet">
</head>
<body>
<canvas id="canvas">
Expand Down Expand Up @@ -41,7 +41,8 @@ <h3>✅ Your browser is compatible!</h3>
</p>
</div>
<div id="boot-compat-failed">
<h3>⛔ Your browser appears to be incompatible.</h3>
<h3 id="boot-compat-failed-error">⛔ Your browser appears to be incompatible.</h3>
<h3 id="boot-compat-failed-warning">⚠️ Your browser may be incompatible.</h3>
<p>
Sorry about that! 💙<br>
You can still try to launch Bosca, but probably it will not work.<br>
Expand Down Expand Up @@ -74,8 +75,8 @@ <h3>⛔ Your browser appears to be incompatible.</h3>
</div>

<script src="$GODOT_URL"></script>
<script src="boscaweb.patches.js?2"></script>
<script src="boscaweb.main.js?2"></script>
<script src="boscaweb.patches.js?3"></script>
<script src="boscaweb.main.js?3"></script>
<script>
const GODOT_CONFIG = $GODOT_CONFIG;
const GODOT_THREADS_ENABLED = $GODOT_THREADS_ENABLED;
Expand Down
183 changes: 158 additions & 25 deletions dist/web_assets/boscaweb.main.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,23 @@ const BOSCAWEB_STATE_PROGRESS = 1;
const BOSCAWEB_STATE_READY = 2;
const BOSCAWEB_STATE_FAILED = 3;

// Can technically be configured with Module["INITIAL_MEMORY"], but we don't have
// access to that as early as we need it. It seems to be unset though, so using
// default should be safe.
const BOSCAWEB_INITIAL_MEMORY = 33554432;
const BOSCAWEB_MAXIMUM_MEMORY = 2147483648; // 2 GB
const BOSCAWEB_MEMORY_PAGE_SIZE = 65536;

const BOSCAWEB_COMPATIBILITY_OK = 0;
const BOSCAWEB_COMPATIBILITY_WARNING = 1;
const BOSCAWEB_COMPATIBILITY_FAILURE = 2;

class BoscaWeb {
constructor() {
this.initializing = true;
this.engine = new Engine(GODOT_CONFIG);
this._allocatedMemory = 0;
this.memory = this._allocateWasmMemory();

this._bootOverlay = document.getElementById('boot-overlay');
this._boot_initialState = document.getElementById('boot-menu');
Expand All @@ -34,61 +47,165 @@ class BoscaWeb {

this._compat_passedState = document.getElementById('boot-compat-passed');
this._compat_failedState = document.getElementById('boot-compat-failed');
this._compat_failedHeaderError = document.getElementById('boot-compat-failed-error');
this._compat_failedHeaderWarning = document.getElementById('boot-compat-failed-warning');
this._compat_failedList = document.getElementById('boot-compat-list');
this._compat_tryfixButton = document.getElementById('boot-compat-tryfix');
this._compat_tryfixButton.addEventListener('click', () => {
this.tryFixCompatibility();
});

this._compatible = false;
this._compatFixable = (GODOT_CONFIG['serviceWorker'] && GODOT_CONFIG['ensureCrossOriginIsolationHeaders'] && 'serviceWorker' in navigator);
this._compatLevel = BOSCAWEB_COMPATIBILITY_OK;
this._compatFixable = (this.memory && GODOT_CONFIG['serviceWorker'] && GODOT_CONFIG['ensureCrossOriginIsolationHeaders'] && 'serviceWorker' in navigator);

// Hidden by default to show native error messages, e.g. if JavaScript
// is disabled in the browser.
this._bootOverlay.style.visibility = 'visible';
this.setState(BOSCAWEB_STATE_INITIAL);
}

_allocateWasmMemory() {
// We will try to allocate as much as possible, starting with the limit that we actually require.
// In Safari this is likely to fail, so we try less and less. This is not guaranteed to work, but
// at least it gives user a chance.
const reductionSteps = [ 1, 0.75, 0.5, 0.25 ];
let reductionIndex = 0;

let wasmMemory = null;
let sizeMessage = '';
while (wasmMemory == null && reductionIndex < reductionSteps.length) {
const reduction = reductionSteps[reductionIndex];
this._allocatedMemory = BOSCAWEB_MAXIMUM_MEMORY * reduction;
sizeMessage = `${this._humanizeSize(BOSCAWEB_INITIAL_MEMORY)} out of ${this._humanizeSize(this._allocatedMemory)}`;

// This can fail if we hit the browser's limit.
try {
wasmMemory = new WebAssembly.Memory({
initial: BOSCAWEB_INITIAL_MEMORY / BOSCAWEB_MEMORY_PAGE_SIZE,
maximum: reduction * BOSCAWEB_MAXIMUM_MEMORY / BOSCAWEB_MEMORY_PAGE_SIZE,
shared: true
});
} catch (err) {
console.error(err);
wasmMemory = null;
}

reductionIndex += 1;
}

if (wasmMemory == null) {
console.error(`Failed to allocate WebAssembly memory (${sizeMessage}); check the limits.`);
return null;
}
if (!(wasmMemory.buffer instanceof SharedArrayBuffer)) {
console.error(`Trying to allocate WebAssembly memory (${sizeMessage}), but returned buffer is not SharedArrayBuffer; this indicates that threading is probably not supported.`);
return null;
}

console.log(`Successfully allocated WebAssembly memory (${sizeMessage}).`);
return wasmMemory;
}

_checkMissingFeatures() {
const missingFeatures = Engine.getMissingFeatures({
threads: GODOT_THREADS_ENABLED,
});

return missingFeatures.map((item) => {
const itemParts = item.split(' - ');
return {
'name': itemParts[0],
'description': itemParts[1] || '',
}
});
}

checkCompatibility() {
this._bootButton.classList.remove('boot-init-suppressed');
this._compat_passedState.style.display = 'none';
this._compat_failedState.style.display = 'none';
this._compat_failedHeaderError.style.display = 'none';
this._compat_failedHeaderWarning.style.display = 'none';
this._compat_tryfixButton.style.display = 'none';
this._compat_failedList.style.display = 'none';
this._setErrorText(this._compat_failedList, '');

const missingFeatures = Engine.getMissingFeatures({
threads: GODOT_THREADS_ENABLED,
});
this._compatLevel = BOSCAWEB_COMPATIBILITY_OK;

if (missingFeatures.length > 0) {
this._compatible = false;
this._bootButton.classList.add('boot-init-suppressed');
this._compat_failedState.style.display = 'flex';
this._compat_failedList.style.display = 'block';
this._compat_tryfixButton.style.display = (this._compatFixable ? 'inline-block' : 'none');
// Check memory allocation.
if (this.memory == null) {
this._lowerCompatibilityLevel(BOSCAWEB_COMPATIBILITY_FAILURE);
this._addCompatibilityLevelReason('Your browser does not allow enough memory');

const sectionHeader = document.createElement('strong');
sectionHeader.textContent = 'Your browser is missing following features: ';
this._compat_failedList.appendChild(sectionHeader);
const reasonDescription = document.createElement('span');
reasonDescription.textContent = `Bosca requested maximum limit of ${this._humanizeSize(BOSCAWEB_MAXIMUM_MEMORY)}, but was refused.`;
this._compat_failedList.appendChild(reasonDescription);
}
else if (this._allocatedMemory < BOSCAWEB_MAXIMUM_MEMORY) {
this._lowerCompatibilityLevel(BOSCAWEB_COMPATIBILITY_WARNING);
this._addCompatibilityLevelReason('Your browser does not allow enough memory');

const reasonDescription = document.createElement('span');
reasonDescription.textContent = `Bosca requested maximum limit of ${this._humanizeSize(BOSCAWEB_MAXIMUM_MEMORY)}, but was only allowed ${this._humanizeSize(this._allocatedMemory)}.`;
this._compat_failedList.appendChild(reasonDescription);
}

// Check for missing browser feature.
const missingFeatures = this._checkMissingFeatures();
if (missingFeatures.length > 0) {
this._lowerCompatibilityLevel(BOSCAWEB_COMPATIBILITY_FAILURE);
this._addCompatibilityLevelReason('Your browser is missing following features');

const sectionList = document.createElement('span');
this._compat_failedList.appendChild(sectionList);
const reasonDescription = document.createElement('span');
this._compat_failedList.appendChild(reasonDescription);
missingFeatures.forEach((item, index) => {
const itemParts = item.split(' - ');

const annotatedElement = document.createElement('abbr');
annotatedElement.textContent = itemParts[0];
annotatedElement.title = itemParts[1];
sectionList.appendChild(annotatedElement);
annotatedElement.textContent = item.name;
annotatedElement.title = item.description;
reasonDescription.appendChild(annotatedElement);

if (index < missingFeatures.length - 1) {
sectionList.appendChild(document.createTextNode(", "));
reasonDescription.appendChild(document.createTextNode(", "));
}
});
} else {
this._compatible = true;
this._compat_passedState.style.display = 'flex';
}

switch (this._compatLevel) {
case BOSCAWEB_COMPATIBILITY_OK:
this._compat_passedState.style.display = 'flex';
break;

case BOSCAWEB_COMPATIBILITY_WARNING:
this._bootButton.classList.add('boot-init-suppressed');
this._compat_failedState.style.display = 'flex';
this._compat_failedHeaderWarning.style.display = 'inline-block';
this._compat_failedList.style.display = 'block';
this._compat_tryfixButton.style.display = (this._compatFixable ? 'inline-block' : 'none');
break;

case BOSCAWEB_COMPATIBILITY_FAILURE:
this._bootButton.classList.add('boot-init-suppressed');
this._compat_failedState.style.display = 'flex';
this._compat_failedHeaderError.style.display = 'inline-block';
this._compat_failedList.style.display = 'block';
this._compat_tryfixButton.style.display = (this._compatFixable ? 'inline-block' : 'none');
break;
}
}

_addCompatibilityLevelReason(message) {
if (this._compat_failedList.hasChildNodes()) {
this._compat_failedList.appendChild(document.createElement('br'));
}

const reasonHeader = document.createElement('strong');
reasonHeader.textContent = `${message}: `;
this._compat_failedList.appendChild(reasonHeader);
}

_lowerCompatibilityLevel(level) {
if (this._compatLevel < level) {
this._compatLevel = level;
}
}

Expand Down Expand Up @@ -205,4 +322,20 @@ class BoscaWeb {
this.setState(BOSCAWEB_STATE_FAILED);
this.initializing = false;
}

_humanizeSize(size) {
const labels = [ 'B', 'KB', 'MB', 'GB', 'TB', ];

let label = labels[0];
let value = size;

let index = 0;
while (value >= 1024 && index < labels.length) {
index += 1;
value = value / 1024;
label = labels[index];
}

return `${value.toFixed(2)} ${label}`;
}
}
24 changes: 24 additions & 0 deletions dist/web_assets/boscaweb.patches.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,27 @@
return forwardedResponse;
}
})(window);

// Monkey-patch the Godot initializer to influence initialization where it cannot be configured.
(function(window){
const _orig_Godot = window.Godot;

window.Godot = function(Module) {
// Use a pre-allocated buffer that uses a safer amount of maximum memory, which
// avoids instant crashes in Safari. Although, there can still be memory issues
// in Safari (both macOS and iOS/iPadOS), with some indication of improvements
// starting with Safari 18.
if (window.bosca.memory != null) {
Module["wasmMemory"] = window.bosca.memory;
}

// The initializer can still throw exceptions, including an out of memory exception.
// Due to nested levels of async and promise handling, this is not captured by
// try-catching Engine.startGame(). But it can be captured here.
try {
return _orig_Godot(Module);
} catch (err) {
window.bosca._fatalError(err);
}
}
})(window);
4 changes: 4 additions & 0 deletions dist/web_assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ ul {
width: 100%;
}

#boot-compat-failed-error, #boot-compat-failed-warning {
display: none;
}

#boot-compat-tryfix {
font-size: 15px;
padding: 6px 18px;
Expand Down

0 comments on commit 5a8a2a4

Please sign in to comment.