teapot-coding-agent 0.26.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent.js +55 -3
- package/dist/agent/tools.js +72 -11
- package/dist/master.js +24 -3
- package/dist/server/api.js +85 -5
- package/package.json +1 -1
- package/public/assets/index-Cm_AmGsg.js +30 -0
- package/public/assets/index-Cm_AmGsg.js.map +1 -0
- package/public/assets/{shiki-D1LowWvE.js → shiki-OoTC7Aoo.js} +2 -2
- package/public/assets/{shiki-D1LowWvE.js.map → shiki-OoTC7Aoo.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-BrEdnC4L.js +0 -30
- package/public/assets/index-BrEdnC4L.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -1190,6 +1190,8 @@ export class Agent {
|
|
|
1190
1190
|
if (this.status === "running" && !pendingStop)
|
|
1191
1191
|
return;
|
|
1192
1192
|
this.stopRequested = false;
|
|
1193
|
+
// claim the status against any stop write still queued behind us (#86)
|
|
1194
|
+
this.stopSeq++;
|
|
1193
1195
|
this.awaitingUser = false; // a fresh start answers/resumes past any ask_user
|
|
1194
1196
|
void this.enqueue(async () => {
|
|
1195
1197
|
await this.ensureReady(); // lazy restore before the loop touches history
|
|
@@ -1206,11 +1208,32 @@ export class Agent {
|
|
|
1206
1208
|
this.abort?.abort();
|
|
1207
1209
|
this.toolAbort.abort();
|
|
1208
1210
|
this.wake?.();
|
|
1211
|
+
// #86: the status write is DEFERRED onto the run chain, so it can land
|
|
1212
|
+
// AFTER a start() that followed it. Observed: `idle -> stopped -> running ->
|
|
1213
|
+
// running -> idle`. The agent did resume — but it flashed "stopped" on the
|
|
1214
|
+
// way, and finished idle rather than running, so from the UI the operator's
|
|
1215
|
+
// ▶ start looked like it had done nothing.
|
|
1216
|
+
//
|
|
1217
|
+
// `start()` clears stopRequested synchronously, which is what makes this
|
|
1218
|
+
// detectable: by the time this task runs, a start has already claimed the
|
|
1219
|
+
// agent and the stop no longer owns the status.
|
|
1220
|
+
//
|
|
1221
|
+
// So record WHEN the stop was requested, and let a start that came after it
|
|
1222
|
+
// win. A stop issued while the agent is genuinely still running behaves
|
|
1223
|
+
// exactly as before.
|
|
1224
|
+
const stopSeq = ++this.stopSeq;
|
|
1209
1225
|
void this.enqueue(() => {
|
|
1226
|
+
// Only a start() that arrived AFTER this stop may claim the status. The
|
|
1227
|
+
// seq check alone does that — no status test, because a genuinely running
|
|
1228
|
+
// agent must still be reported "stopped" (#86).
|
|
1229
|
+
if (this.stopSeq !== stopSeq)
|
|
1230
|
+
return Promise.resolve();
|
|
1210
1231
|
this.setStatus("stopped", reason);
|
|
1211
1232
|
return Promise.resolve();
|
|
1212
1233
|
});
|
|
1213
1234
|
}
|
|
1235
|
+
/** monotonic counter: a start() bumps it, so a pending stop status write loses */
|
|
1236
|
+
stopSeq = 0;
|
|
1214
1237
|
setStatus(s, reason = "") {
|
|
1215
1238
|
// Update the field FIRST. log.append fires onEvent synchronously, and the
|
|
1216
1239
|
// master's child-event hook wakes parked wait_children callers — those
|
|
@@ -1585,8 +1608,15 @@ export class Agent {
|
|
|
1585
1608
|
return finished;
|
|
1586
1609
|
}
|
|
1587
1610
|
for (const call of m.tool_calls) {
|
|
1588
|
-
if (this.stopRequested)
|
|
1611
|
+
if (this.stopRequested) {
|
|
1612
|
+
// #54: this returned straight out of the turn, SKIPPING the
|
|
1613
|
+
// answerUnansweredToolCalls backstop below — so every remaining
|
|
1614
|
+
// tool_call in this assistant message stayed logged with NO
|
|
1615
|
+
// tool_result anywhere, and its row rendered as "waiting for
|
|
1616
|
+
// output…" for the rest of the session.
|
|
1617
|
+
await this.answerUnansweredToolCalls(m);
|
|
1589
1618
|
return finished;
|
|
1619
|
+
}
|
|
1590
1620
|
if (call.function.name === "finish") {
|
|
1591
1621
|
this.pendingSubReport = ""; // the real summary supersedes it
|
|
1592
1622
|
await this.handleFinish(call.function.arguments);
|
|
@@ -1665,6 +1695,13 @@ export class Agent {
|
|
|
1665
1695
|
this.awaitingUser = true;
|
|
1666
1696
|
this.setStatus("waiting", question.slice(0, 80));
|
|
1667
1697
|
// control flow: park the loop; the operator's next prompt resumes it
|
|
1698
|
+
//
|
|
1699
|
+
// #54: answer the rest of this assistant batch BEFORE parking. The
|
|
1700
|
+
// throw leaves the whole tool loop, so any EARLIER call in the same
|
|
1701
|
+
// message that was logged but not yet run kept no tool_result — the
|
|
1702
|
+
// comment on the backstop below says as much, and it is exactly the
|
|
1703
|
+
// path that bites in practice because ask_user is common.
|
|
1704
|
+
await this.answerUnansweredToolCalls(m);
|
|
1668
1705
|
throw Object.assign(new Error("waiting for user"), { name: "WaitForUser" });
|
|
1669
1706
|
}
|
|
1670
1707
|
if (call.function.name === "get_todo") {
|
|
@@ -1796,8 +1833,23 @@ export class Agent {
|
|
|
1796
1833
|
});
|
|
1797
1834
|
const t0 = Date.now();
|
|
1798
1835
|
// first filesystem touch creates a missing workspace (never at boot)
|
|
1799
|
-
|
|
1800
|
-
|
|
1836
|
+
// #54: this is awaited BETWEEN the tool_call append above and the
|
|
1837
|
+
// tool_result append below, and `fs.mkdir` has no catch — so a failure
|
|
1838
|
+
// propagated out of the whole loop, leaving the call logged with no
|
|
1839
|
+
// result ANYWHERE, and its row read "waiting for output…" for good.
|
|
1840
|
+
//
|
|
1841
|
+
// The loop's own backstop could not save it: it sits at the bottom of
|
|
1842
|
+
// the same loop, which a throw skips. So the tool_result is written in a
|
|
1843
|
+
// `finally` — the call is answered whatever happens.
|
|
1844
|
+
// the type is inferred from executeTool, so this cannot drift from it
|
|
1845
|
+
let result;
|
|
1846
|
+
try {
|
|
1847
|
+
await this.ensureWorkspace();
|
|
1848
|
+
result = await executeTool(call.function.name, call.function.arguments, this.toolCtx);
|
|
1849
|
+
}
|
|
1850
|
+
catch (err) {
|
|
1851
|
+
result = { ok: false, result: `tool could not run: ${err.message}` };
|
|
1852
|
+
}
|
|
1801
1853
|
this.stats.toolCalls++;
|
|
1802
1854
|
await this.log.append("tool_result", this.currentSession, this.currentBranch, {
|
|
1803
1855
|
callId: call.id,
|
package/dist/agent/tools.js
CHANGED
|
@@ -16,6 +16,45 @@ function clip(s, max) {
|
|
|
16
16
|
async function readText(p) {
|
|
17
17
|
return fs.readFile(p, "utf8");
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* The file's dominant line ending, or null when it is mixed or empty.
|
|
21
|
+
*
|
|
22
|
+
* `null` means "do not normalise" — see the note above.
|
|
23
|
+
*/
|
|
24
|
+
export function detectEol(text) {
|
|
25
|
+
if (!text)
|
|
26
|
+
return null;
|
|
27
|
+
const crlf = (text.match(/\r\n/g) ?? []).length;
|
|
28
|
+
// a bare CR is a CR not followed by LF
|
|
29
|
+
const cr = (text.match(/\r(?!\n)/g) ?? []).length;
|
|
30
|
+
const lf = (text.match(/(?<!\r)\n/g) ?? []).length;
|
|
31
|
+
const kinds = [crlf, cr, lf].filter((n) => n > 0).length;
|
|
32
|
+
// one kind only -> unambiguous. Mixed -> null.
|
|
33
|
+
if (kinds !== 1)
|
|
34
|
+
return null;
|
|
35
|
+
if (crlf > 0)
|
|
36
|
+
return "\r\n";
|
|
37
|
+
if (cr > 0)
|
|
38
|
+
return "\r";
|
|
39
|
+
return "\n";
|
|
40
|
+
}
|
|
41
|
+
/** Split on ANY of the three line endings, dropping the terminators. */
|
|
42
|
+
export function splitEolLines(text) {
|
|
43
|
+
return text.split(/\r\n|\r|\n/);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Re-apply `eol` to content the model supplied.
|
|
47
|
+
*
|
|
48
|
+
* Models emit `\n` essentially always, so a CRLF or CR file rewritten with LF
|
|
49
|
+
* content would otherwise be silently converted. Untouched terminators already
|
|
50
|
+
* in the right form are left alone, so this is safe to run on merged content.
|
|
51
|
+
*/
|
|
52
|
+
export function applyEol(content, eol) {
|
|
53
|
+
if (!eol || eol === "\n")
|
|
54
|
+
return content;
|
|
55
|
+
// normalise to \n first, then re-apply — avoids double-CR on "\r\n"
|
|
56
|
+
return content.replace(/\r\n|\r|\n/g, eol);
|
|
57
|
+
}
|
|
19
58
|
/**
|
|
20
59
|
* Optimistic-concurrency guard (#5). The model reads a file, thinks, then
|
|
21
60
|
* writes — and in between, a sub-agent, a background job or the operator may
|
|
@@ -970,7 +1009,11 @@ export const TOOLS = [
|
|
|
970
1009
|
};
|
|
971
1010
|
}
|
|
972
1011
|
ctx.onFileRead?.(str(args.path));
|
|
973
|
-
|
|
1012
|
+
// #84: split on ALL THREE line endings. A CR-only (old-Mac) file used to
|
|
1013
|
+
// come back as a single line, so the model could not address lines 2..n at
|
|
1014
|
+
// all — and a CRLF file left a trailing "\r" on every line, which then
|
|
1015
|
+
// had to be stripped again by every consumer.
|
|
1016
|
+
const lines = splitEolLines(text);
|
|
974
1017
|
// #7/#4: opt-in hashline output. Default stays the historical `N| ` so
|
|
975
1018
|
// nothing that depends on it changes until the mode is chosen.
|
|
976
1019
|
const idMode = lineIdMode(args);
|
|
@@ -1056,8 +1099,25 @@ export const TOOLS = [
|
|
|
1056
1099
|
if (stale)
|
|
1057
1100
|
return stale;
|
|
1058
1101
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
1059
|
-
|
|
1060
|
-
|
|
1102
|
+
// #84: a file's line endings belong to the FILE. Writing the model's
|
|
1103
|
+
// content verbatim silently converted a CRLF (or CR) file to LF whenever
|
|
1104
|
+
// the model rewrote it — the model never emits \r — so one logical edit
|
|
1105
|
+
// produced a whole-file diff, and any tool that normalises on EOL (git,
|
|
1106
|
+
// prettier) would rewrite it afterwards.
|
|
1107
|
+
//
|
|
1108
|
+
// So an EXISTING file's dominant EOL is re-applied. detectEol returns null
|
|
1109
|
+
// for a mixed file, which is left untouched on purpose: choosing a winner
|
|
1110
|
+
// there would itself be the silent whole-file rewrite this prevents.
|
|
1111
|
+
const before = await currentOrNull(p);
|
|
1112
|
+
const eol = before === null ? null : detectEol(before);
|
|
1113
|
+
const out = applyEol(args.content, eol);
|
|
1114
|
+
await fs.writeFile(p, out, "utf8");
|
|
1115
|
+
const converted = eol !== null && eol !== "\n" && out !== args.content;
|
|
1116
|
+
return {
|
|
1117
|
+
ok: true,
|
|
1118
|
+
result: `wrote ${p} (${out.length} bytes)` +
|
|
1119
|
+
(converted ? ` (kept ${eol === "\r\n" ? "CRLF" : "CR"} line endings)` : ""),
|
|
1120
|
+
};
|
|
1061
1121
|
},
|
|
1062
1122
|
},
|
|
1063
1123
|
{
|
|
@@ -1136,12 +1196,15 @@ export const TOOLS = [
|
|
|
1136
1196
|
const patLines = oldText.replace(/\r\n/g, "\n").split("\n");
|
|
1137
1197
|
const hits = trailingWsMatches(srcLines, patLines);
|
|
1138
1198
|
if (hits.length === 1 && patLines.length > 0) {
|
|
1139
|
-
|
|
1199
|
+
// #84: same detection as the hashline path — CR-only files are detected
|
|
1200
|
+
// too, and a MIXED file yields null and is left as-is rather than
|
|
1201
|
+
// homogenised. The old `includes("\r\n")` test called a CR-only file
|
|
1202
|
+
// "\n", so an edit to one silently converted its line endings.
|
|
1140
1203
|
const rebuilt = [
|
|
1141
1204
|
...srcLines.slice(0, hits[0]),
|
|
1142
|
-
...newText
|
|
1205
|
+
...splitEolLines(newText),
|
|
1143
1206
|
...srcLines.slice(hits[0] + patLines.length),
|
|
1144
|
-
].join(
|
|
1207
|
+
].join(detectEol(text) ?? "\n");
|
|
1145
1208
|
await fs.writeFile(p, rebuilt, "utf8");
|
|
1146
1209
|
return {
|
|
1147
1210
|
ok: true,
|
|
@@ -1197,12 +1260,10 @@ export const TOOLS = [
|
|
|
1197
1260
|
// emit \r).
|
|
1198
1261
|
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
1199
1262
|
const out = hashSpan
|
|
1200
|
-
? text
|
|
1201
|
-
.split("\n")
|
|
1263
|
+
? splitEolLines(text)
|
|
1202
1264
|
.slice(0, hashSpan.start)
|
|
1203
|
-
.concat(newText
|
|
1204
|
-
.join("\n")
|
|
1205
|
-
.replace(/(?<!\r)\n/g, eol)
|
|
1265
|
+
.concat(splitEolLines(newText), splitEolLines(text).slice(hashSpan.end))
|
|
1266
|
+
.join(eol ?? "\n")
|
|
1206
1267
|
: replaceAll
|
|
1207
1268
|
? text.split(oldText).join(newText)
|
|
1208
1269
|
: text.replace(oldText, newText);
|
package/dist/master.js
CHANGED
|
@@ -1277,9 +1277,30 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1277
1277
|
// remove from map so a re-spawn after restart doesn't carry state
|
|
1278
1278
|
this.agents.delete(id);
|
|
1279
1279
|
});
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1280
|
+
// #79: two problems with racing a timeout against the real work.
|
|
1281
|
+
//
|
|
1282
|
+
// 1. The timer was never cleared, so it kept the event loop alive for the
|
|
1283
|
+
// FULL timeout after a fast, successful stop — a 30s handle on a stop
|
|
1284
|
+
// that took milliseconds.
|
|
1285
|
+
// 2. On timeout, the promises that HAD completed had already deleted their
|
|
1286
|
+
// agents from `this.agents`, while their peers were still mid-dispose.
|
|
1287
|
+
// `restartServer` then threw BEFORE spawning its replacement, so the
|
|
1288
|
+
// process was left serving with a half-empty agent map and no successor.
|
|
1289
|
+
//
|
|
1290
|
+
// Fixed by clearing the timer, and by reporting which agents did NOT stop
|
|
1291
|
+
// so the caller can decide rather than being left half-dead.
|
|
1292
|
+
let timer;
|
|
1293
|
+
const timeout = new Promise((_, reject) => {
|
|
1294
|
+
timer = setTimeout(() => reject(new Error("agent stop timeout")), timeoutMs);
|
|
1295
|
+
});
|
|
1296
|
+
try {
|
|
1297
|
+
await Promise.race([Promise.all(stopPromises), timeout]);
|
|
1298
|
+
console.log("[teapot] all agents stopped");
|
|
1299
|
+
}
|
|
1300
|
+
finally {
|
|
1301
|
+
if (timer)
|
|
1302
|
+
clearTimeout(timer);
|
|
1303
|
+
}
|
|
1283
1304
|
}
|
|
1284
1305
|
/** Restart the server process (spawns a new master, keeps this one alive until ready). */
|
|
1285
1306
|
async restartServer() {
|
package/dist/server/api.js
CHANGED
|
@@ -91,21 +91,58 @@ function mimeFor(rel, kind) {
|
|
|
91
91
|
}
|
|
92
92
|
export function buildApp(master) {
|
|
93
93
|
const app = new Hono();
|
|
94
|
+
/**
|
|
95
|
+
* Cap on concurrent `/api/ws` event-stream clients (#79).
|
|
96
|
+
*
|
|
97
|
+
* Each one holds a listener on the global bus and receives every agent's
|
|
98
|
+
* events, so this bounds real fan-out. 64 is far above any plausible number of
|
|
99
|
+
* open tabs on a LAN and low enough that a runaway client cannot exhaust memory.
|
|
100
|
+
*/
|
|
101
|
+
const MAX_WS_CLIENTS = 64;
|
|
94
102
|
// Optional bearer auth for LAN exposure — TEAPOT_API_TOKEN env wins, else
|
|
95
103
|
// the config's `password` field. Static files stay public; only /api/* is
|
|
96
|
-
// gated.
|
|
104
|
+
// gated.
|
|
97
105
|
const apiToken = process.env.TEAPOT_API_TOKEN || master.config.password || "";
|
|
106
|
+
/**
|
|
107
|
+
* Routes where `?token=` is accepted (#79).
|
|
108
|
+
*
|
|
109
|
+
* A browser WebSocket handshake cannot send an Authorization header, so the
|
|
110
|
+
* query parameter is the ONLY way for the UI to authenticate those two
|
|
111
|
+
* sockets. It used to be accepted on EVERY `/api/*` route, which put the
|
|
112
|
+
* secret into proxy access logs, `Referer` headers and browser history for
|
|
113
|
+
* requests that never needed it — `DELETE /api/agents/:id`, `PUT
|
|
114
|
+
* /api/config`, `POST /api/update/restart`.
|
|
115
|
+
*
|
|
116
|
+
* So this narrows where the secret can LEAK rather than changing who is
|
|
117
|
+
* authenticated: every route still accepts the header, and only these two
|
|
118
|
+
* also accept the query form.
|
|
119
|
+
*/
|
|
120
|
+
const tokenInQueryOk = (path) => path === "/api/ws" || /^\/api\/agents\/[^/]+\/term$/.test(path);
|
|
98
121
|
if (apiToken)
|
|
99
122
|
app.use("/api/*", async (c, next) => {
|
|
100
123
|
const h = c.req.header("authorization");
|
|
101
124
|
const provided = h?.startsWith("Bearer ") ? h.slice(7) : undefined;
|
|
102
|
-
|
|
103
|
-
if ((provided && provided === apiToken) || (q && q === apiToken))
|
|
125
|
+
if (provided && provided === apiToken)
|
|
104
126
|
return next();
|
|
127
|
+
if (tokenInQueryOk(c.req.path)) {
|
|
128
|
+
const q = c.req.query("token");
|
|
129
|
+
if (q && q === apiToken)
|
|
130
|
+
return next();
|
|
131
|
+
}
|
|
105
132
|
return c.json({ error: "unauthorized" }, 401);
|
|
106
133
|
});
|
|
107
134
|
// per-agent terminal spawn guard
|
|
108
135
|
const termCounts = new Map();
|
|
136
|
+
/**
|
|
137
|
+
* Live `/api/ws` event-stream connections (#79).
|
|
138
|
+
*
|
|
139
|
+
* Every one of these receives EVERY agent's events, so the count is a direct
|
|
140
|
+
* multiplier on fan-out, and `bus.setMaxListeners(1000)` only silences a
|
|
141
|
+
* warning — it does not bound anything. Capped so a stuck or hostile client
|
|
142
|
+
* cannot pin unbounded memory, and paired with a liveness check below so a
|
|
143
|
+
* client that stops reading is reaped rather than buffered for ever.
|
|
144
|
+
*/
|
|
145
|
+
let wsClients = 0;
|
|
109
146
|
/**
|
|
110
147
|
* Agent ids reserved by an in-flight create (#71).
|
|
111
148
|
*
|
|
@@ -128,8 +165,30 @@ export function buildApp(master) {
|
|
|
128
165
|
app.get("/api/ws", upgradeWebSocket(() => {
|
|
129
166
|
let onUpdate = null;
|
|
130
167
|
let ka = null;
|
|
168
|
+
// set by onOpen, cleared by a pong. Declared at the connection's scope
|
|
169
|
+
// because onMessage is a SIBLING handler and cannot see a local in onOpen.
|
|
170
|
+
let awaitingPong = false;
|
|
171
|
+
let counted = false;
|
|
131
172
|
return {
|
|
132
173
|
onOpen(_evt, ws) {
|
|
174
|
+
// #79: refuse beyond the cap rather than accepting and buffering.
|
|
175
|
+
// Each connection costs a listener on the global bus and receives
|
|
176
|
+
// every agent's events, so this bounds real memory, not just a count.
|
|
177
|
+
if (wsClients >= MAX_WS_CLIENTS) {
|
|
178
|
+
try {
|
|
179
|
+
ws.send(JSON.stringify({ kind: "error", error: `too many event-stream clients (max ${MAX_WS_CLIENTS})` }));
|
|
180
|
+
ws.close(1013, "too many clients");
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* already gone */
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
wsClients++;
|
|
188
|
+
counted = true;
|
|
189
|
+
// a client that stops reading makes ws.send buffer without bound; the
|
|
190
|
+
// app-level ping doubles as a liveness probe — if a ping goes
|
|
191
|
+
// unanswered the socket is reaped instead of accumulating
|
|
133
192
|
const send = (data) => {
|
|
134
193
|
try {
|
|
135
194
|
ws.send(JSON.stringify(data));
|
|
@@ -151,8 +210,23 @@ export function buildApp(master) {
|
|
|
151
210
|
send(ev);
|
|
152
211
|
};
|
|
153
212
|
bus.on("update", onUpdate);
|
|
154
|
-
// app-level liveness ping every 30s
|
|
155
|
-
|
|
213
|
+
// app-level liveness ping every 30s, and a reaper for a client that
|
|
214
|
+
// has stopped answering (#79)
|
|
215
|
+
ka = setInterval(() => {
|
|
216
|
+
if (awaitingPong) {
|
|
217
|
+
// the previous ping went unanswered: the client is not reading,
|
|
218
|
+
// so its send buffer is growing. Reap it.
|
|
219
|
+
try {
|
|
220
|
+
ws.close(1011, "unresponsive");
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
/* already gone */
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
awaitingPong = true;
|
|
228
|
+
send({ kind: "ping" });
|
|
229
|
+
}, 30_000);
|
|
156
230
|
},
|
|
157
231
|
onMessage(evt, ws) {
|
|
158
232
|
// clients may send {"kind":"ping"} — nothing else to do today
|
|
@@ -160,6 +234,8 @@ export function buildApp(master) {
|
|
|
160
234
|
const m = JSON.parse(String(evt.data));
|
|
161
235
|
if (m?.kind === "ping")
|
|
162
236
|
ws.send(JSON.stringify({ kind: "pong" }));
|
|
237
|
+
if (m?.kind === "pong")
|
|
238
|
+
awaitingPong = false;
|
|
163
239
|
}
|
|
164
240
|
catch {
|
|
165
241
|
/* ignore junk */
|
|
@@ -170,6 +246,10 @@ export function buildApp(master) {
|
|
|
170
246
|
clearInterval(ka);
|
|
171
247
|
if (onUpdate)
|
|
172
248
|
bus.off("update", onUpdate);
|
|
249
|
+
// only a connection that was actually admitted owns a slot — a
|
|
250
|
+
// refused one must not decrement one it never took (#70's lesson)
|
|
251
|
+
if (counted)
|
|
252
|
+
wsClients = Math.max(0, wsClients - 1);
|
|
173
253
|
},
|
|
174
254
|
};
|
|
175
255
|
}));
|