Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebSocket Memory Leak on Android and iOS #16732

Closed
SteffenC opened this issue Nov 7, 2017 · 6 comments
Closed

WebSocket Memory Leak on Android and iOS #16732

SteffenC opened this issue Nov 7, 2017 · 6 comments
Labels
Resolution: Locked This issue was locked by the bot.

Comments

@SteffenC
Copy link

SteffenC commented Nov 7, 2017

Memory usage through 1:33 minute
screen shot 2017-11-07 at 14 19 05

Is this a bug report?

Yes.

Have you read the Contributing Guidelines?

Yes.

Environment

Environment:
  OS:  macOS High Sierra 10.13.1
  Node:  8.5.0
  Yarn:  1.0.2
  npm:  5.3.0
  Watchman:  4.9.0
  Xcode:  Xcode 9.1 Build version 9B55
  Android Studio:  3.0 AI-171.4408382

Packages: (wanted => installed)
  react: 16.0.0-beta.5 => 16.0.0-beta.5
  react-native: 0.49.1 => 0.49.1

Steps to Reproduce

  1. react-native init test
  2. Change content of app.js to the provided example
  3. Add provided "Speedtester.js" file in src/Speedtester.js
  4. run project, and notice the memory usage keep increasing.

Expected Behavior

I expected the memory usage to increase during the speedtest and then decrease again afterwards.

Actual Behavior

The memory usage increases during the test but never decreases.

Reproducible Demo

App.js

import React, { Component } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Speedtester from './src/Speedtester';

export default class App extends Component {
  constructor() {
    super();
    this.state = { rate: 0 };
  }

  componentDidMount() {
    this.runTest();
  }

  runTest() {
    const sptest = new Speedtester();
      sptest.runDownloadTest(10000, (rate) => {
      this.setState({ rate });
    }, () => { 
      setTimeout(() => {
        this.runTest() 
      }, 10000)
    });
  }

  render() {
    return (
      <View style={{ flex: 1, justifyContent: 'center' }}>
        <Text style={styles.rate}>
          {this.state.rate}
        </Text>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  rate: {
    fontSize: 20,
    textAlign: 'center',
    margin: 10,
  },
});

Speedtester.js

/* eslint-disable */
import React, { Component } from 'react';
import { View, Text } from 'react-native';

class Speedtester {
	constructor() {
		if (!String.prototype.startsWith) {
			String.prototype.startsWith = function (searchString, position) {
				return this.substr(position || 0, searchString.length) === searchString;
			};
		}
	}

	runLatencyTest(onprogress, end) {
		var target = "wss://speedtest.fullrate.dk/backend/ws";
		Promise.resolve()
			.then(function () {
				return latencytest({
					target: target,
				}).then(function (samples) {
					for (var i = 0; i < samples.length; i++) {
						onprogress(samples[i])
					}
				});
			}).then(end);
	}

	runDownloadTest(duration, onprogress, end) {
		var target = "wss://speedtest.fullrate.dk/backend/ws";
		Promise.resolve()
			.then(function () {
				return speedtest({
					target: target,
					session: session_download,
					duration: duration ||  10000,
					finish: end,
					progressinterval: 800,
					onprogress: function (speed, elapsed, total, time) {
						if (speed > 30) {
							var rate = speed.toFixed(0);
						} else {
							var rate = speed.toFixed(1);
						}
						if (time) {
							var eta = time;
						} else {
							var eta = 0;
						}
						onprogress(rate, eta);
					}
				});
			});
	}

	runUploadTest(duration, onprogress, end) {
		var target = "wss://speedtest.fullrate.dk/backend/ws";
		const tmpWs = new WebSocket(target);
		tmpWs.binaryType = 'blob';

		tmpWs.onopen = function (event) {
			send = true;
			const size = (1 * 1024 * 1024);
			tmpWs.send('DOWNLOAD ' + size);

			tmpWs.onmessage = function (event) {
				const uploadbuf = event.data;
				tmpWs.close();
				
				var rate = 0;
				var eta = 0;
				Promise.resolve()
					.then(function () {
						return speedtest({
							target: target,
							session: session_upload,
							duration: 10000,
							finish: end,
							uploadbuf: uploadbuf,
							onprogress: function (speed, elapsed, total, time) {
								if (speed > 30) {
									rate = speed.toFixed(0);
								} else {
									rate = speed.toFixed(1);
								}
								if (time) {
									eta = time;
								} else {
									eta = 0;
								}
								onprogress(rate, eta);
							}
						});
					});
			}
		}
	}

	stopTest() {
		if(_cleanup) {
			_cleanup();
		}
	}
}

/**
* SPEED TEST FUNCS
*/
function now() {
	return (new Date).getTime();
}

function uint8tostr(data) {
	return String.fromCharCode.apply(null, new Uint8Array(data));
}

// Returns a function that will cancel the session
function session(target, queuelen, onreceive, handler) {
	var sendtimes = [];
	var replies = 0;
	var nextinc = 0;
	var size = handler.minsize;
	var mintime = 100;
	var exit = false;

	function fill_queue() {
		if (exit) return;
		while (sendtimes.length < queuelen) {
			sendtimes.push(now());
			ws.send(handler.req(size));
		}
	}
	var ws = new WebSocket(target);
	ws.binaryType = handler.resulttype;
	ws.onopen = function () {
		if (exit) return;
		fill_queue();
	}
	ws.onclose = function () {
		if (exit) return;
		exit = true;
		onreceive("closed");
	}
	ws.onerror = function (e) {
		if (exit) return;
		exit = true;
		onreceive(e);
	};
	ws.onmessage = function (e) {
		if (exit) return;
		var v = handler.rep(e.data);
		if (v === false) {
			exit = true;
			onreceive("Unexpected data returned");
			return;
		}
		onreceive(undefined, v);
		var td = now() - sendtimes.shift();
		if (td < mintime && nextinc <= replies && size < handler.maxsize) {
			size = size * 2;
			nextinc = replies + queuelen;
			if (size > handler.maxsize) size = handler.maxsize;
		}
		fill_queue();
		replies++;
	};

	return function () {
		exit = true;
		return new Promise(function (resolve) {
			ws.onclose = ws.onerror = function () {
				resolve();
			};
			ws.close();
		});
	};
}

function session_download(target, queuelen, uploadbuf, onreceive) {
	return session(target, queuelen, onreceive, {
		resulttype: 'blob',
		minsize: 10000,
		maxsize: 1000000,
		req: function req_download(size) {
			return 'DOWNLOAD ' + size;
		},
		rep: function rep_download(data) {
			var s = data.size || data.byteLength; 
			return s;
		},
	});
}

//var uploadbuf = new Blob([new Uint8Array(1 * 1024 * 1024)]);
function session_upload(target, queuelen, uploadbuf, onreceive) {
	return session(target, queuelen, onreceive, {
		resulttype: 'arraybuffer',
		minsize: 10000,
		maxsize: 1000000,
		req: function req_upload(size) {
			var s = uploadbuf.slice(size);
			return s;
		},
		rep: function rep_upload(data) {
			var str = uint8tostr(data);
			if (!str.startsWith('OK ')) return false;
			var s = parseInt(str.substr(3), 10);
			return s;
		},
	});
}

var timeRemaining;
var elapsed;
var delta;
var _cleanup;
function speedtest(opts) {
	var target = opts.target;
	if (!target) return Promise.reject("No target URL");
	var session = opts.session;
	if (!session) return Promise.reject("No session");
	var duration = opts.duration || 10000;
	var progressinterval = opts.progress_interval || 400;
	var queuelen = opts.queuelen || 10;
	var concurrency = opts.concurrency || 5;
	var total = 0;
	var begintime = now();
	var samples = [[0, 0]];
	var smoothtime = opts.smoothtime || 3000;
	var first;
	timeRemaining = duration;
	return new Promise(function (resolve, reject) {
		var cancels = [];
		for (var i = 0; i < concurrency; i++) {
			cancels.push(session(target, queuelen, opts.uploadbuf, function (err, amount) {
				if (err) {
					cleanup();
				}
				total += amount;
			}));
		}

		var interval = setInterval(function () {
			elapsed = now() - begintime;
			while (samples.length > 0 && samples[0][0] < (elapsed - smoothtime)) samples.shift();
			samples.push([elapsed, total]);
			var first = samples[0];
			delta = samples.length <= 1 || elapsed === first[0] ? 0 : (total - first[1]) / (elapsed - first[0]);
			timeRemaining = timeRemaining - progressinterval;
			var speed = 8 * delta / 1000;
			if (opts.onprogress) opts.onprogress(speed, elapsed, total, timeRemaining);
		}, progressinterval);

		var timeout = setTimeout(function () {
			cleanup();
			opts.finish();
		}, duration);

		if (opts.onprogress) opts.onprogress(0, 0, 0, 0);

		function cleanup() {
			if (opts.onprogress) opts.onprogress(8 * delta / 1000, elapsed, total, 0);
			clearInterval(interval);
			clearTimeout(timeout);
			return Promise.all(cancels.map(function (c) { return c(); }));
		}

		_cleanup = cleanup;

	});
}

function latencytest(opts) {
	const probes = opts.probes || 20;
	var target = opts.target;
	if (!target) return Promise.reject("No target URL");

	return new Promise(function (resolve, reject) {
		const results = [];
		const ws = new WebSocket(target);
		ws.binaryType = 'arraybuffer';

		function sendping() {
			ws.send('PING ' + now());
		}

		ws.onopen = function () {
			sendping();
		};
		ws.onclose = function () {
			resolve(results);
		};
		ws.onerror = function () {
			reject("An error occured"); // TODO?
		};
		ws.onmessage = function (e) {
			var msg = uint8tostr(e.data);
			// if (msg.indexOf('PONG ') !== -1) reject("Invalid response from server"); appearantly this breaks on android?
			var ts = parseInt(msg.substr(5), 10);
			var delta = now() - ts;
			if (delta === 0) delta = 1;
			results.push(delta);
			if (results.length < probes) {
				sendping();
			} else {
				ws.close();
			}
		};
	});
}

export default Speedtester;

Memory usage shown in Instruments
instruments-code

Call tree showing memory usage
instruments

@kesha-antonov
Copy link
Contributor

+1 ! Very interested in fixing this!

@balsloev
Copy link

balsloev commented Nov 8, 2017

@kesha-antonov just to be clear.
Are you interested in this issue to be solved or are you intersted in solving this by yourself?

@kesha-antonov
Copy link
Contributor

@balsloev To be solved. I don't have enough knowledge to fix this

@SteffenC
Copy link
Author

SteffenC commented Dec 18, 2017

The issue can finally be resolved. After digging around in the implementation of WebSockets and especially the blobs, I found that all blobs will stay in memory as long as they aren't being directly closed.

This means that after you're done with the received data, you should close the Blob like this:

ws.onmessage = function (e) {
  // Do whatever with the data through e.data.
  const data = e.data;
  // When you are done with the received data, you must close the Blob:
  e.data.close();
};

@kesha-antonov
Copy link
Contributor

Thanks!

kesha-antonov added a commit to kesha-antonov/react-native-action-cable that referenced this issue Dec 18, 2017
@lll000111
Copy link

lll000111 commented May 31, 2018

@SteffenC This is only for binaryType === 'blob' (but not for 'arraybuffer')?

Where does the close() method come from anyway?

EDIT: Found it in https://github.com/facebook/react-native/blob/master/Libraries/Blob/Blob.js#L119

@facebook facebook locked as resolved and limited conversation to collaborators Dec 18, 2018
@react-native-bot react-native-bot added the Resolution: Locked This issue was locked by the bot. label Dec 18, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Resolution: Locked This issue was locked by the bot.
Projects
None yet
Development

No branches or pull requests

5 participants