Remove channel reference counter

This was an old attempt at gracefully unloading channels that still had
pending callbacks.  Its implementation was always flawed, and the number
of places where it was used is small enough to replace with
straightforward checks for whether the channel has been unloaded after
an asynchronous operation.  Hopefully fixes the stuck 0 user channels
issue.
This commit is contained in:
Calvin Montgomery 2020-11-11 22:04:04 -08:00
parent 66fadab492
commit d678fa56d1
9 changed files with 64 additions and 158 deletions

View file

@ -16,10 +16,6 @@ describe('KickbanModule', () => {
beforeEach(() => { beforeEach(() => {
mockChannel = { mockChannel = {
name: channelName, name: channelName,
refCounter: {
ref() { },
unref() { }
},
logger: { logger: {
log() { } log() { }
}, },
@ -69,11 +65,22 @@ describe('KickbanModule', () => {
}); });
}); });
function patch(fn, after) {
let existing = kickban[fn];
kickban[fn] = async function () {
try {
await existing.apply(this, arguments)
} finally {
after();
}
};
}
describe('#handleCmdBan', () => { describe('#handleCmdBan', () => {
it('inserts a valid ban', done => { it('inserts a valid ban', done => {
let kicked = false; let kicked = false;
mockChannel.refCounter.unref = () => { patch('banName', () => {
assert(kicked, 'Expected user to be kicked'); assert(kicked, 'Expected user to be kicked');
database.getDB().runTransaction(async tx => { database.getDB().runTransaction(async tx => {
@ -90,7 +97,7 @@ describe('KickbanModule', () => {
done(); done();
}); });
}; });
mockChannel.users = [{ mockChannel.users = [{
getLowerName() { getLowerName() {
@ -247,7 +254,7 @@ describe('KickbanModule', () => {
let firstUserKicked = false; let firstUserKicked = false;
let secondUserKicked = false; let secondUserKicked = false;
mockChannel.refCounter.unref = () => { patch('banAll', () => {
assert(firstUserKicked, 'Expected banned user to be kicked'); assert(firstUserKicked, 'Expected banned user to be kicked');
assert( assert(
secondUserKicked, secondUserKicked,
@ -279,7 +286,7 @@ describe('KickbanModule', () => {
done(); done();
}); });
}; });
mockChannel.users = [{ mockChannel.users = [{
getLowerName() { getLowerName() {
@ -313,7 +320,7 @@ describe('KickbanModule', () => {
}); });
it('inserts a valid range ban', done => { it('inserts a valid range ban', done => {
mockChannel.refCounter.unref = () => { patch('banIP', () => {
database.getDB().runTransaction(async tx => { database.getDB().runTransaction(async tx => {
const ipBan = await tx.table('channel_bans') const ipBan = await tx.table('channel_bans')
.where({ .where({
@ -328,7 +335,7 @@ describe('KickbanModule', () => {
done(); done();
}); });
}; });
kickban.handleCmdIPBan( kickban.handleCmdIPBan(
mockUser, mockUser,
@ -338,7 +345,7 @@ describe('KickbanModule', () => {
}); });
it('inserts a valid wide-range ban', done => { it('inserts a valid wide-range ban', done => {
mockChannel.refCounter.unref = () => { patch('banIP', () => {
database.getDB().runTransaction(async tx => { database.getDB().runTransaction(async tx => {
const ipBan = await tx.table('channel_bans') const ipBan = await tx.table('channel_bans')
.where({ .where({
@ -353,7 +360,7 @@ describe('KickbanModule', () => {
done(); done();
}); });
}; });
kickban.handleCmdIPBan( kickban.handleCmdIPBan(
mockUser, mockUser,
@ -365,7 +372,7 @@ describe('KickbanModule', () => {
it('inserts a valid IPv6 ban', done => { it('inserts a valid IPv6 ban', done => {
const longIP = require('../../lib/utilities').expandIPv6('::abcd'); const longIP = require('../../lib/utilities').expandIPv6('::abcd');
mockChannel.refCounter.unref = () => { patch('banAll', () => {
database.getDB().runTransaction(async tx => { database.getDB().runTransaction(async tx => {
const ipBan = await tx.table('channel_bans') const ipBan = await tx.table('channel_bans')
.where({ .where({
@ -380,7 +387,7 @@ describe('KickbanModule', () => {
done(); done();
}); });
}; });
database.getDB().runTransaction(async tx => { database.getDB().runTransaction(async tx => {
await tx.table('aliases') await tx.table('aliases')
@ -546,7 +553,7 @@ describe('KickbanModule', () => {
}); });
it('still adds the IP ban even if the name is already banned', done => { it('still adds the IP ban even if the name is already banned', done => {
mockChannel.refCounter.unref = () => { patch('banIP', () => {
database.getDB().runTransaction(async tx => { database.getDB().runTransaction(async tx => {
const ipBan = await tx.table('channel_bans') const ipBan = await tx.table('channel_bans')
.where({ .where({
@ -561,7 +568,7 @@ describe('KickbanModule', () => {
done(); done();
}); });
}; });
database.getDB().runTransaction(tx => { database.getDB().runTransaction(tx => {
return tx.table('channel_bans') return tx.table('channel_bans')

View file

@ -2,7 +2,7 @@
"author": "Calvin Montgomery", "author": "Calvin Montgomery",
"name": "CyTube", "name": "CyTube",
"description": "Online media synchronizer and chat", "description": "Online media synchronizer and chat",
"version": "3.73.0", "version": "3.74.0",
"repository": { "repository": {
"url": "http://github.com/calzoneman/sync" "url": "http://github.com/calzoneman/sync"
}, },

View file

@ -14,67 +14,6 @@ const LOGGER = require('@calzoneman/jsli')('channel');
const USERCOUNT_THROTTLE = 10000; const USERCOUNT_THROTTLE = 10000;
class ReferenceCounter {
constructor(channel) {
this.channel = channel;
this.channelName = channel.name;
this.refCount = 0;
this.references = {};
}
ref(caller) {
if (caller) {
if (this.references.hasOwnProperty(caller)) {
this.references[caller]++;
} else {
this.references[caller] = 1;
}
}
this.refCount++;
}
unref(caller) {
if (caller) {
if (this.references.hasOwnProperty(caller)) {
this.references[caller]--;
if (this.references[caller] === 0) {
delete this.references[caller];
}
} else {
LOGGER.error("ReferenceCounter::unref() called by caller [" +
caller + "] but this caller had no active references! " +
`(channel: ${this.channelName})`);
return;
}
}
this.refCount--;
this.checkRefCount();
}
checkRefCount() {
if (this.refCount === 0) {
if (Object.keys(this.references).length > 0) {
LOGGER.error("ReferenceCounter::refCount reached 0 but still had " +
"active references: " +
JSON.stringify(Object.keys(this.references)) +
` (channel: ${this.channelName})`);
for (var caller in this.references) {
this.refCount += this.references[caller];
}
} else if (this.channel.users && this.channel.users.length > 0) {
LOGGER.error("ReferenceCounter::refCount reached 0 but still had " +
this.channel.users.length + " active users" +
` (channel: ${this.channelName})`);
this.refCount = this.channel.users.length;
} else {
this.channel.emit("empty");
}
}
}
}
function Channel(name) { function Channel(name) {
this.name = name; this.name = name;
this.uniqueName = name.toLowerCase(); this.uniqueName = name.toLowerCase();
@ -85,7 +24,6 @@ function Channel(name) {
) )
); );
this.users = []; this.users = [];
this.refCounter = new ReferenceCounter(this);
this.flags = 0; this.flags = 0;
this.id = 0; this.id = 0;
this.ownerName = null; this.ownerName = null;
@ -282,17 +220,16 @@ Channel.prototype.saveState = async function () {
Channel.prototype.checkModules = function (fn, args, cb) { Channel.prototype.checkModules = function (fn, args, cb) {
const self = this; const self = this;
const refCaller = `Channel::checkModules/${fn}`;
this.waitFlag(Flags.C_READY, function () { this.waitFlag(Flags.C_READY, function () {
if (self.dead) return; if (self.dead) return;
self.refCounter.ref(refCaller);
var keys = Object.keys(self.modules); var keys = Object.keys(self.modules);
var next = function (err, result) { var next = function (err, result) {
if (self.dead) return;
if (result !== ChannelModule.PASSTHROUGH) { if (result !== ChannelModule.PASSTHROUGH) {
/* Either an error occured, or the module denied the user access */ /* Either an error occured, or the module denied the user access */
cb(err, result); cb(err, result);
self.refCounter.unref(refCaller);
return; return;
} }
@ -300,7 +237,6 @@ Channel.prototype.checkModules = function (fn, args, cb) {
if (m === undefined) { if (m === undefined) {
/* No more modules to check */ /* No more modules to check */
cb(null, ChannelModule.PASSTHROUGH); cb(null, ChannelModule.PASSTHROUGH);
self.refCounter.unref(refCaller);
return; return;
} }
@ -339,28 +275,32 @@ Channel.prototype.notifyModules = function (fn, args) {
Channel.prototype.joinUser = function (user, data) { Channel.prototype.joinUser = function (user, data) {
const self = this; const self = this;
self.refCounter.ref("Channel::user");
self.waitFlag(Flags.C_READY, function () { self.waitFlag(Flags.C_READY, function () {
/* User closed the connection before the channel finished loading */ /* User closed the connection before the channel finished loading */
if (user.socket.disconnected) { if (user.socket.disconnected) {
self.refCounter.unref("Channel::user"); return;
}
if (self.dead) {
user.kick('Channel is not loaded');
return; return;
} }
user.channel = self; user.channel = self;
user.waitFlag(Flags.U_LOGGED_IN, () => { user.waitFlag(Flags.U_LOGGED_IN, () => {
if (self.dead) { if (self.dead) {
LOGGER.warn( user.kick('Channel is not loaded');
'Got U_LOGGED_IN for %s after channel already unloaded',
user.getName()
);
return; return;
} }
if (user.is(Flags.U_REGISTERED)) { if (user.is(Flags.U_REGISTERED)) {
db.channels.getRank(self.name, user.getName(), (error, rank) => { db.channels.getRank(self.name, user.getName(), (error, rank) => {
if (!error) { if (!error) {
if (self.dead) {
user.kick('Channel is not loaded');
return;
}
user.setChannelRank(rank); user.setChannelRank(rank);
user.setFlag(Flags.U_HAS_CHANNEL_RANK); user.setFlag(Flags.U_HAS_CHANNEL_RANK);
if (user.inChannel()) { if (user.inChannel()) {
@ -374,13 +314,6 @@ Channel.prototype.joinUser = function (user, data) {
} }
}); });
if (user.socket.disconnected) {
self.refCounter.unref("Channel::user");
return;
} else if (self.dead) {
return;
}
self.checkModules("onUserPreJoin", [user, data], function (err, result) { self.checkModules("onUserPreJoin", [user, data], function (err, result) {
if (result === ChannelModule.PASSTHROUGH) { if (result === ChannelModule.PASSTHROUGH) {
user.channel = self; user.channel = self;
@ -389,7 +322,6 @@ Channel.prototype.joinUser = function (user, data) {
user.channel = null; user.channel = null;
user.account.channelRank = 0; user.account.channelRank = 0;
user.account.effectiveRank = user.account.globalRank; user.account.effectiveRank = user.account.globalRank;
self.refCounter.unref("Channel::user");
} }
}); });
}); });
@ -493,8 +425,8 @@ Channel.prototype.partUser = function (user) {
}); });
this.broadcastUsercount(); this.broadcastUsercount();
this.refCounter.unref("Channel::user");
user.die(); user.die();
if (this.users.length === 0) this.emit('empty');
}; };
Channel.prototype.maybeResendUserlist = function maybeResendUserlist(user, newRank, oldRank) { Channel.prototype.maybeResendUserlist = function maybeResendUserlist(user, newRank, oldRank) {
@ -655,13 +587,14 @@ Channel.prototype.sendUserJoin = function (users, user) {
Channel.prototype.readLog = function (cb) { Channel.prototype.readLog = function (cb) {
const maxLen = 102400; const maxLen = 102400;
const file = this.logger.filename; const file = this.logger.filename;
this.refCounter.ref("Channel::readLog");
const self = this; const self = this;
fs.stat(file, function (err, data) { fs.stat(file, function (err, data) {
if (err) { if (err) {
self.refCounter.unref("Channel::readLog");
return cb(err, null); return cb(err, null);
} }
if (self.dead) {
return cb(new Error('Channel unloaded'), null);
}
const start = Math.max(data.size - maxLen, 0); const start = Math.max(data.size - maxLen, 0);
const end = data.size - 1; const end = data.size - 1;
@ -677,7 +610,6 @@ Channel.prototype.readLog = function (cb) {
}); });
read.on("end", function () { read.on("end", function () {
cb(null, buffer); cb(null, buffer);
self.refCounter.unref("Channel::readLog");
}); });
}); });
}; };
@ -744,10 +676,6 @@ Channel.prototype.packInfo = function (isAdmin) {
} }
} }
if (isAdmin) {
data.activeLockCount = this.refCounter.refCount;
}
var self = this; var self = this;
var keys = Object.keys(this.modules); var keys = Object.keys(this.modules);
keys.forEach(function (k) { keys.forEach(function (k) {

View file

@ -77,10 +77,11 @@ KickBanModule.prototype.onUserPostJoin = function (user) {
} }
const chan = this.channel; const chan = this.channel;
const refCaller = "KickBanModule::onUserPostJoin";
user.waitFlag(Flags.U_LOGGED_IN, function () { user.waitFlag(Flags.U_LOGGED_IN, function () {
chan.refCounter.ref(refCaller);
db.channels.isNameBanned(chan.name, user.getName(), function (err, banned) { db.channels.isNameBanned(chan.name, user.getName(), function (err, banned) {
if (chan.dead) {
return;
}
if (!err && banned) { if (!err && banned) {
user.kick("You are banned from this channel."); user.kick("You are banned from this channel.");
if (chan.modules.chat) { if (chan.modules.chat) {
@ -88,7 +89,6 @@ KickBanModule.prototype.onUserPostJoin = function (user) {
"name is banned)"); "name is banned)");
} }
} }
chan.refCounter.unref(refCaller);
}); });
}); });
@ -226,14 +226,9 @@ KickBanModule.prototype.handleCmdBan = function (user, msg, _meta) {
var name = args.shift().toLowerCase(); var name = args.shift().toLowerCase();
var reason = args.join(" "); var reason = args.join(" ");
const chan = this.channel;
chan.refCounter.ref("KickBanModule::handleCmdBan");
this.banName(user, name, reason).catch(error => { this.banName(user, name, reason).catch(error => {
const message = error.message || error; const message = error.message || error;
user.socket.emit("errorMsg", { msg: message }); user.socket.emit("errorMsg", { msg: message });
}).then(() => {
chan.refCounter.unref("KickBanModule::handleCmdBan");
}); });
}; };
@ -257,15 +252,9 @@ KickBanModule.prototype.handleCmdIPBan = function (user, msg, _meta) {
} }
var reason = args.join(" "); var reason = args.join(" ");
const chan = this.channel;
chan.refCounter.ref("KickBanModule::handleCmdIPBan");
this.banAll(user, name, range, reason).catch(error => { this.banAll(user, name, range, reason).catch(error => {
//console.log('!!!', error.stack);
const message = error.message || error; const message = error.message || error;
user.socket.emit("errorMsg", { msg: message }); user.socket.emit("errorMsg", { msg: message });
}).then(() => {
chan.refCounter.unref("KickBanModule::handleCmdIPBan");
}); });
}; };
@ -427,14 +416,15 @@ KickBanModule.prototype.handleUnban = function (user, data) {
} }
var self = this; var self = this;
this.channel.refCounter.ref("KickBanModule::handleUnban");
db.channels.unbanId(this.channel.name, data.id, function (err) { db.channels.unbanId(this.channel.name, data.id, function (err) {
if (err) { if (err) {
self.channel.refCounter.unref("KickBanModule::handleUnban");
return user.socket.emit("errorMsg", { return user.socket.emit("errorMsg", {
msg: err msg: err
}); });
} }
if (self.channel.dead) {
return;
}
self.sendUnban(self.channel.users, data); self.sendUnban(self.channel.users, data);
self.channel.logger.log("[mod] " + user.getName() + " unbanned " + data.name); self.channel.logger.log("[mod] " + user.getName() + " unbanned " + data.name);
@ -445,7 +435,6 @@ KickBanModule.prototype.handleUnban = function (user, data) {
banperm banperm
); );
} }
self.channel.refCounter.unref("KickBanModule::handleUnban");
}); });
}; };

View file

@ -55,18 +55,15 @@ LibraryModule.prototype.handleUncache = function (user, data) {
} }
const chan = this.channel; const chan = this.channel;
chan.refCounter.ref("LibraryModule::handleUncache");
db.channels.deleteFromLibrary(chan.name, data.id, function (err, _res) { db.channels.deleteFromLibrary(chan.name, data.id, function (err, _res) {
if (chan.dead) { if (chan.dead) {
return; return;
} else if (err) { } else if (err) {
chan.refCounter.unref("LibraryModule::handleUncache");
return; return;
} }
chan.logger.log("[library] " + user.getName() + " deleted " + data.id + chan.logger.log("[library] " + user.getName() + " deleted " + data.id +
"from the library"); "from the library");
chan.refCounter.unref("LibraryModule::handleUncache");
}); });
}; };

View file

@ -47,9 +47,8 @@ MediaRefresherModule.prototype.initVimeo = function (data, cb) {
} }
const self = this; const self = this;
self.channel.refCounter.ref("MediaRefresherModule::initVimeo");
Vimeo.extract(data.id).then(function (direct) { Vimeo.extract(data.id).then(function (direct) {
if (self.dead || self.channel.dead) { if (self.channel.dead) {
self.unload(); self.unload();
return; return;
} }
@ -63,9 +62,11 @@ MediaRefresherModule.prototype.initVimeo = function (data, cb) {
if (cb) cb(); if (cb) cb();
}).catch(function (err) { }).catch(function (err) {
LOGGER.error("Unexpected vimeo::extract() fail: " + err.stack); LOGGER.error("Unexpected vimeo::extract() fail: " + err.stack);
if (self.channel.dead) {
self.unload();
return;
}
if (cb) cb(); if (cb) cb();
}).finally(() => {
self.channel.refCounter.unref("MediaRefresherModule::initVimeo");
}); });
}; };

View file

@ -511,19 +511,19 @@ PlaylistModule.prototype.queueStandard = function (user, data) {
}; };
const self = this; const self = this;
this.channel.refCounter.ref("PlaylistModule::queueStandard");
counters.add("playlist:queue:count", 1); counters.add("playlist:queue:count", 1);
this.semaphore.queue(function (lock) { this.semaphore.queue(function (lock) {
InfoGetter.getMedia(data.id, data.type, function (err, media) { InfoGetter.getMedia(data.id, data.type, function (err, media) {
if (err) { if (err) {
error(XSS.sanitizeText(String(err))); error(XSS.sanitizeText(String(err)));
self.channel.refCounter.unref("PlaylistModule::queueStandard"); return lock.release();
}
if (self.channel.dead) {
return lock.release(); return lock.release();
} }
self._addItem(media, data, user, function () { self._addItem(media, data, user, function () {
lock.release(); lock.release();
self.channel.refCounter.unref("PlaylistModule::queueStandard");
}); });
}); });
}); });
@ -546,7 +546,7 @@ PlaylistModule.prototype.queueYouTubePlaylist = function (user, data) {
return lock.release(); return lock.release();
} }
if (self.dead) { if (self.channel.dead) {
return lock.release(); return lock.release();
} }
@ -562,8 +562,6 @@ PlaylistModule.prototype.queueYouTubePlaylist = function (user, data) {
} }
} }
self.channel.refCounter.ref("PlaylistModule::queueYouTubePlaylist");
if (self.channel.modules.library && data.shouldAddToLibrary) { if (self.channel.modules.library && data.shouldAddToLibrary) {
self.channel.modules.library.cacheMediaList(vids); self.channel.modules.library.cacheMediaList(vids);
data.shouldAddToLibrary = false; data.shouldAddToLibrary = false;
@ -574,8 +572,6 @@ PlaylistModule.prototype.queueYouTubePlaylist = function (user, data) {
self._addItem(media, data, user); self._addItem(media, data, user);
}); });
self.channel.refCounter.unref("PlaylistModule::queueYouTubePlaylist");
lock.release(); lock.release();
}); });
}); });
@ -593,7 +589,6 @@ PlaylistModule.prototype.handleDelete = function (user, data) {
} }
var plitem = this.items.find(data); var plitem = this.items.find(data);
self.channel.refCounter.ref("PlaylistModule::handleDelete");
this.semaphore.queue(function (lock) { this.semaphore.queue(function (lock) {
if (self._delete(data)) { if (self._delete(data)) {
self.channel.logger.log("[playlist] " + user.getName() + " deleted " + self.channel.logger.log("[playlist] " + user.getName() + " deleted " +
@ -601,7 +596,6 @@ PlaylistModule.prototype.handleDelete = function (user, data) {
} }
lock.release(); lock.release();
self.channel.refCounter.unref("PlaylistModule::handleDelete");
}); });
}; };
@ -637,26 +631,24 @@ PlaylistModule.prototype.handleMoveMedia = function (user, data) {
} }
const self = this; const self = this;
self.channel.refCounter.ref("PlaylistModule::handleMoveMedia");
self.semaphore.queue(function (lock) { self.semaphore.queue(function (lock) {
if (self.channel.dead) {
return lock.release();
}
if (!self.items.remove(data.from)) { if (!self.items.remove(data.from)) {
self.channel.refCounter.unref("PlaylistModule::handleMoveMedia");
return lock.release(); return lock.release();
} }
if (data.after === "prepend") { if (data.after === "prepend") {
if (!self.items.prepend(from)) { if (!self.items.prepend(from)) {
self.channel.refCounter.unref("PlaylistModule::handleMoveMedia");
return lock.release(); return lock.release();
} }
} else if (data.after === "append") { } else if (data.after === "append") {
if (!self.items.append(from)) { if (!self.items.append(from)) {
self.channel.refCounter.unref("PlaylistModule::handleMoveMedia");
return lock.release(); return lock.release();
} }
} else { } else {
if (!self.items.insertAfter(from, data.after)) { if (!self.items.insertAfter(from, data.after)) {
self.channel.refCounter.unref("PlaylistModule::handleMoveMedia");
return lock.release(); return lock.release();
} }
} }
@ -668,7 +660,6 @@ PlaylistModule.prototype.handleMoveMedia = function (user, data) {
(after ? " after " + after.media.title : "")); (after ? " after " + after.media.title : ""));
self._listDirty = true; self._listDirty = true;
lock.release(); lock.release();
self.channel.refCounter.unref("PlaylistModule::handleMoveMedia");
}); });
}; };
@ -1356,14 +1347,15 @@ PlaylistModule.prototype.handleQueuePlaylist = function (user, data) {
}; };
const self = this; const self = this;
self.channel.refCounter.ref("PlaylistModule::handleQueuePlaylist");
db.getUserPlaylist(user.getName(), data.name, function (err, pl) { db.getUserPlaylist(user.getName(), data.name, function (err, pl) {
if (err) { if (err) {
self.channel.refCounter.unref("PlaylistModule::handleQueuePlaylist");
return user.socket.emit("errorMsg", { return user.socket.emit("errorMsg", {
msg: "Playlist load failed: " + err msg: "Playlist load failed: " + err
}); });
} }
if (self.channel.dead) {
return;
}
try { try {
if (data.pos === "next") { if (data.pos === "next") {
@ -1398,8 +1390,6 @@ PlaylistModule.prototype.handleQueuePlaylist = function (user, data) {
msg: "Internal error occurred when loading playlist.", msg: "Internal error occurred when loading playlist.",
link: null link: null
}); });
} finally {
self.channel.refCounter.unref("PlaylistModule::handleQueuePlaylist");
} }
}); });
}; };

View file

@ -357,11 +357,9 @@ Server.prototype.unloadChannel = function (chan, options) {
LOGGER.info("Unloaded channel " + chan.name); LOGGER.info("Unloaded channel " + chan.name);
chan.broadcastUsercount.cancel(); chan.broadcastUsercount.cancel();
// Empty all outward references from the channel // Empty all outward references from the channel | TODO does this actually help?
Object.keys(chan).forEach(key => { Object.keys(chan).forEach(key => {
if (key !== "refCounter") { delete chan[key];
delete chan[key];
}
}); });
chan.dead = true; chan.dead = true;
promActiveChannels.dec(); promActiveChannels.dec();

View file

@ -421,10 +421,6 @@ function showChannelDetailModal(c) {
$("<td/>").text("Public").appendTo(tr); $("<td/>").text("Public").appendTo(tr);
$("<td/>").text(c.public).appendTo(tr); $("<td/>").text(c.public).appendTo(tr);
tr = $("<tr/>").appendTo(table);
$("<td/>").text("ActiveLock Count").appendTo(tr);
$("<td/>").text(c.activeLockCount).appendTo(tr);
tr = $("<tr/>").appendTo(table); tr = $("<tr/>").appendTo(table);
$("<td/>").text("Chat Filter Count").appendTo(tr); $("<td/>").text("Chat Filter Count").appendTo(tr);
$("<td/>").text(c.chatFilterCount).appendTo(tr); $("<td/>").text(c.chatFilterCount).appendTo(tr);