@helping-ai-workflow/md2doc 3.2.1 → 3.4.0

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.
@@ -2,6 +2,8 @@
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const http = require('http');
5
+ const crypto = require('crypto');
6
+ const drawioLib = require('../drawio.js');
5
7
  const { renderMarkdown } = require('../md2doc.js');
6
8
 
7
9
  const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
@@ -54,6 +56,50 @@ const TURNDOWN_SRC = fs.readFileSync(
54
56
  require.resolve('turndown/lib/turndown.browser.umd.js'), 'utf8');
55
57
  const PASTE_MD_SRC = fs.readFileSync(path.join(__dirname, 'paste-md.js'), 'utf8');
56
58
 
59
+ // v3.4.0 batch3 Task 6: the wavedrom GUI's four modules.
60
+ //
61
+ // wave-codec.js / wave-geometry.js / wave-store.js are plain CommonJS ON
62
+ // PURPOSE, and rewriting them into the UMD header every other module here uses
63
+ // is not available: test/wave-codec.test.js bans the spelling `require(` in the
64
+ // codec outright, test/wave-geometry.test.js bans every host-object read a UMD
65
+ // header performs (`typeof self`, `root.x = …`), and test/wave-store.test.js
66
+ // pins the store's require list to exactly `["require('./wave-codec.js')"]`. So
67
+ // the browser gets them through the small CommonJS shim below instead —
68
+ // the module text itself is inlined byte for byte.
69
+ //
70
+ // wave-ui.js is UMD like its neighbours and needs no shim; in the page it
71
+ // defines `window.md2docWaveUi` and takes the other three as parameters.
72
+ const WAVE_CODEC_SRC = fs.readFileSync(path.join(__dirname, 'wave-codec.js'), 'utf8');
73
+ const WAVE_GEOMETRY_SRC = fs.readFileSync(path.join(__dirname, 'wave-geometry.js'), 'utf8');
74
+ const WAVE_STORE_SRC = fs.readFileSync(path.join(__dirname, 'wave-store.js'), 'utf8');
75
+ const WAVE_UI_SRC = fs.readFileSync(path.join(__dirname, 'wave-ui.js'), 'utf8');
76
+
77
+ // One CommonJS module, wrapped so a plain <script> can carry it.
78
+ //
79
+ // The `'use strict'` is the FIRST statement of the script, not of the wrapper
80
+ // function, so the whole tag — module body included — is strict. That is
81
+ // load-bearing rather than tidy: the store deep-freezes every document it
82
+ // keeps, and a frozen object only BITES in strict mode. test/wave-store.test.js
83
+ // T24 measured the sloppy-mode alternative — four of five writes to a frozen
84
+ // document were silent — so a wrapper that lost the prologue would put the
85
+ // original corruption back and make it quieter.
86
+ //
87
+ // `require` here resolves by basename against what has already been shimmed,
88
+ // which is enough for the only two edges that exist (both './wave-codec.js')
89
+ // and stays a total function for anything else: an unknown name answers
90
+ // undefined and the module that asked fails where it asked.
91
+ function waveModuleTag(name, src) {
92
+ return '<script>\'use strict\';\n' +
93
+ 'window.__md2docWave = window.__md2docWave || {};\n' +
94
+ '(function () {\n' +
95
+ 'var module = { exports: {} };\n' +
96
+ 'var exports = module.exports;\n' +
97
+ 'function require(p) { return window.__md2docWave[String(p).split(\'/\').pop()]; }\n' +
98
+ src + '\n' +
99
+ 'window.__md2docWave[' + JSON.stringify(name) + '] = module.exports;\n' +
100
+ '})();</script>\n';
101
+ }
102
+
57
103
  // The SAME pure module the page gets, required here for the node side of
58
104
  // /api/asset. asset.js is deliberately free of `require('fs')`/`require('path')`
59
105
  // (it is plain string arithmetic), so every filesystem decision below is made
@@ -145,15 +191,290 @@ function assertBlockRangesFit(blocks, lines) {
145
191
  }
146
192
  }
147
193
 
148
- async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '', listenPort = 0 }) {
194
+ // F13: the never-opened grace deadline is deliberately its own constant,
195
+ // not idleTimeoutMs reused. idleTimeoutMs governs the gap since the last
196
+ // browser ping (client.js pings every 10s; the default 30,000ms tolerates
197
+ // a short run of missed pings once a real tab is confirmed present).
198
+ // Before the URL is opened there is no ping to measure against — the wait
199
+ // is instead for a human to alt-tab to a browser, or, over SSH, copy the
200
+ // printed URL across to a local machine and load it there, which routinely
201
+ // takes longer than idleTimeoutMs allows. Reusing idleTimeoutMs for that
202
+ // wait would close sessions on people still reading the terminal output.
203
+ // Five minutes is chosen as a margin generous for that human handoff while
204
+ // still finite; test/cli-edit.test.js drives it through a shortened
205
+ // override — an unopened session still exits on its own, and a session
206
+ // opened partway through the grace window keeps working afterward.
207
+ const DEFAULT_NEVER_OPENED_GRACE_MS = 5 * 60 * 1000;
208
+
209
+ // T21 item 3: closing this server has to mean "stop serving now", not "stop
210
+ // serving once every open socket happens to go away". `server.close()` on its
211
+ // own does the latter: it stops accepting, then waits for the connections it
212
+ // already has. A browser handed the edit URL opens a socket it may never send
213
+ // a request on (a speculative preconnect), and such a socket is not one Node
214
+ // retires by itself — driven against `md2doc --edit` on this box with a
215
+ // viewer stub that opened one TCP connection to the port and sent no bytes,
216
+ // SIGINT printed cli.js's line and the process was still alive 10 s later,
217
+ // 3 runs of 3 — while the same runs with a viewer stub that connected and
218
+ // sent a request, and with `--no-open`, exited in 7-10 ms. The same hang
219
+ // reaches the idle and never-opened deadlines below, which have no second
220
+ // keystroke to rescue them, so every exit this file owns goes through here.
221
+ //
222
+ // closeAllConnections() lands the sockets that close() would wait on. It is
223
+ // Node >= 18.2 and package.json's engines floor is >= 18, so the call is
224
+ // guarded rather than assumed; on an 18.0/18.1 runtime the behaviour is
225
+ // exactly what it was before this function existed.
226
+ function shutdownServer(server, reason) {
227
+ // F13: name the reason before closing, so cli.js's listener (the process's
228
+ // stdout writer) can print a line distinct from the other exits.
229
+ if (reason) server.emit('md2doc-auto-close', reason);
230
+ server.close();
231
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
232
+ }
233
+
234
+ async function createEditorServer({
235
+ files, idleTimeoutMs = 30000, neverOpenedGraceMs = DEFAULT_NEVER_OPENED_GRACE_MS,
236
+ clientJs = '', listenPort = 0,
237
+ }) {
149
238
  const absFiles = files.map((f) => path.resolve(f));
150
239
  let idleTimer = null;
240
+ let neverOpenedTimer = null;
151
241
  let started = false;
152
242
 
243
+ // v3.4.0 batch2 Task 6 — Ruling B2-2: watch every `.drawio`/`.xml` file the
244
+ // CURRENTLY OPEN document references, and let /api/ping's already-firing
245
+ // 10s heartbeat (see its own comment below) double as the staleness check,
246
+ // rather than inventing a push transport server.js has never had (no
247
+ // WebSocket/SSE/fs.watch anywhere in this stack — see this task's
248
+ // investigation note).
249
+ //
250
+ // TWO maps, and the split is the whole point of fix round 1:
251
+ //
252
+ // drawioWatch fileId -> { refs }. WHAT THE LAST BAKE RESOLVED.
253
+ // The list of absolute paths that render actually resolved,
254
+ // i.e. the only paths worth stat'ing for this document.
255
+ // Fix round 2 (re-review G13): this used to carry a
256
+ // `stamps` Map as well, which nothing ever read — the new
257
+ // tab's baseline comes from trackDrawioRefs()'s RETURN
258
+ // value, handed straight to registerDrawioClient() in the
259
+ // same expression. Dead state in a freshly written
260
+ // mechanism is a maintenance trap, so it is gone; the stamp
261
+ // lib/md2doc.js takes before the bake (review F4) still
262
+ // travels, just not through here.
263
+ //
264
+ // drawioClients clientKey -> { fileId, stamps, pending, lastSeen }.
265
+ // WHAT ONE BROWSER TAB IS ACTUALLY SHOWING. Review F3: the
266
+ // baseline used to be per-file, so with two tabs open on one
267
+ // document the first to ping consumed the one signal and the
268
+ // second sat on a stale diagram looking perfectly healthy.
269
+ // A tab gets its id minted by GET /edit/:id — the same
270
+ // response that paints its DOM — so its baseline is exactly
271
+ // the bytes it is displaying, with no window in which
272
+ // another tab's render could seed it wrong.
273
+ //
274
+ // Review F2's invariant: a client's `stamps` advances ONLY when that client
275
+ // has told us its DOM now shows those bytes. Until then every heartbeat
276
+ // keeps reporting the same staleness, so every one of the client's bail
277
+ // paths (network error, mid-gesture DOM, focus inside the block, …) really
278
+ // does get retried on the next tick instead of losing the change forever.
279
+ const drawioWatch = new Map();
280
+ const drawioClients = new Map();
281
+ // The token that identifies a CONTENT VERSION, not a ping. Minted once per
282
+ // (client, observed stamp set) and repeated verbatim on every later ping
283
+ // that still sees the same bytes — if it were per-ping, a refresh that took
284
+ // longer than one 10s beat would always ack a token the server had already
285
+ // superseded, and the pair would never converge.
286
+ let drawioTokenSeq = 0;
287
+ let drawioLastSweep = 0;
288
+ // 10 minutes = 60 missed heartbeats. A tab that has not pinged in that long
289
+ // is closed (or its machine slept); keeping its entry would leak one small
290
+ // object per tab ever opened for the life of the server process.
291
+ const DRAWIO_CLIENT_IDLE_MS = 10 * 60 * 1000;
292
+
293
+ function drawioClientKey(fileId, clientId) {
294
+ if (typeof clientId !== 'string' || clientId === '') return null;
295
+ if (typeof fileId !== 'number' || !isFinite(fileId)) return null;
296
+ return String(fileId) + '\u0000' + clientId;
297
+ }
298
+
299
+ function sweepDrawioClients(now) {
300
+ if (now - drawioLastSweep < 60 * 1000) return;
301
+ drawioLastSweep = now;
302
+ for (const [k, c] of drawioClients) {
303
+ if (now - c.lastSeen > DRAWIO_CLIENT_IDLE_MS) drawioClients.delete(k);
304
+ }
305
+ }
306
+
307
+ // Fix round 2 (re-review G5). A tab that is still talking to us is alive by
308
+ // definition, so a well-formed clientId with no entry gets one back rather
309
+ // than being answered 204 forever with no signal and no route back but a
310
+ // reload. Reachable the moment a laptop sleeps or a background tab's timers
311
+ // are frozen for longer than the sweep window — and before this, the tab's
312
+ // own returning heartbeat was what deleted it (the sweep ran BEFORE the
313
+ // lastSeen refresh), so the failure mode was silent, permanent, and caused
314
+ // by the recovery attempt itself.
315
+ //
316
+ // Seeded with an EMPTY stamp map on purpose, not with the last bake's: we
317
+ // do not know what that tab's DOM is showing, and an empty baseline makes
318
+ // the next check report stale, which costs one re-render the client then
319
+ // acks. Wrong in the safe direction (one redundant bake) instead of the
320
+ // unsafe one (a tab that silently never updates again).
321
+ //
322
+ // Fix round 3 (re-review2 H4): the bound is EVICT-OLDEST, applied at BOTH
323
+ // doors. Round 2 capped only the ping door and refused past the cap, which
324
+ // was wrong twice over: `GET /edit/:id` wrote into the same map with no
325
+ // check at all, and refusing meant that once 64 entries accumulated, a
326
+ // genuinely swept live tab could never re-register — G5's own guard rail
327
+ // reintroducing exactly the silent deafness G5 was raised about. Dropping
328
+ // the least-recently-seen entry instead keeps the map at <= MAX from both
329
+ // doors AND never turns a live tab away: the entry we drop is by
330
+ // construction the one that has gone quietest.
331
+ //
332
+ // WHAT THIS BOUNDS, EXACTLY (fix round 4, re-review3 M3 — the sentence this
333
+ // replaces claimed more than the code delivers): **the map, not the render
334
+ // load.** Round 2's refuse-at-cap did cap the bakes a fresh-id-per-ping
335
+ // caller could provoke — past 64 ids it answered a bare 204, so no token and
336
+ // no `/api/render` followed. Evict-oldest removes that ceiling on purpose:
337
+ // a caller rotating a new id every ping now gets `{stale, token}` every
338
+ // time and can drive `/api/render` — and therefore headless-Chromium bakes
339
+ // — without bound. That trade is deliberate, because the refusal it removes
340
+ // is exactly what made a swept live tab permanently deaf, and availability
341
+ // for the real tab is worth more here than a ceiling on a caller that is
342
+ // already inside the loopback-only, Host-checked front door (see the
343
+ // DNS-rebinding note above `hostIsLoopback`). Recorded as a known
344
+ // limitation rather than guarded: every guard added to this mechanism so
345
+ // far has produced the next round's finding.
346
+ const DRAWIO_CLIENT_MAX = 64;
347
+ function makeRoomForDrawioClient() {
348
+ while (drawioClients.size >= DRAWIO_CLIENT_MAX) {
349
+ let oldestKey = null, oldestSeen = Infinity;
350
+ for (const [k, c] of drawioClients) {
351
+ if (c.lastSeen < oldestSeen) { oldestSeen = c.lastSeen; oldestKey = k; }
352
+ }
353
+ if (oldestKey === null) return; // unreachable while size > 0
354
+ drawioClients.delete(oldestKey);
355
+ }
356
+ }
357
+ function ensureDrawioClient(fileId, clientId, now) {
358
+ const key = drawioClientKey(fileId, clientId);
359
+ if (key === null) return null;
360
+ let c = drawioClients.get(key);
361
+ if (c) { c.lastSeen = now; return c; }
362
+ if (!drawioWatch.has(fileId)) return null;
363
+ makeRoomForDrawioClient();
364
+ c = { fileId, stamps: new Map(), pending: null, lastSeen: now };
365
+ drawioClients.set(key, c);
366
+ return c;
367
+ }
368
+
369
+ // Called after every render that actually re-reads the source markdown
370
+ // (GET /edit/:id and POST /api/render both call renderMarkdown() fresh).
371
+ // Records WHAT WAS BAKED; deliberately does NOT touch any client's
372
+ // baseline — that is review F2's invariant, and the trap F2 names is
373
+ // exactly this function: /api/render runs BEFORE the client has decided
374
+ // whether it can apply the result, so advancing anything here would lose
375
+ // every post-response bail.
376
+ function trackDrawioRefs(fileId, refs) {
377
+ const list = Array.isArray(refs) ? refs : [];
378
+ const paths = [];
379
+ const stamps = new Map();
380
+ for (const r of list) {
381
+ const abs = r && typeof r.path === 'string' ? r.path : null;
382
+ if (!abs || stamps.has(abs)) continue;
383
+ paths.push(abs);
384
+ stamps.set(abs, r.stamp === undefined ? null : r.stamp);
385
+ }
386
+ drawioWatch.set(fileId, { refs: paths });
387
+ return stamps;
388
+ }
389
+
390
+ function registerDrawioClient(fileId, stamps) {
391
+ const clientId = crypto.randomBytes(12).toString('hex');
392
+ const key = drawioClientKey(fileId, clientId);
393
+ if (key === null) return clientId; // unreachable today; never key on null
394
+ const now = Date.now();
395
+ makeRoomForDrawioClient(); // H4: this door is bounded too
396
+ drawioClients.set(key, {
397
+ fileId, stamps: new Map(stamps), pending: null, lastSeen: now,
398
+ });
399
+ sweepDrawioClients(now); // G5: after the write, never before
400
+ return clientId;
401
+ }
402
+
403
+ function sameStamps(a, b) {
404
+ if (a.size !== b.size) return false;
405
+ for (const [k, v] of a) { if (!b.has(k) || b.get(k) !== v) return false; }
406
+ return true;
407
+ }
408
+
409
+ // (a) requirement: a document with no drawio references must cost this
410
+ // heartbeat NOTHING beyond the Map lookups below — zero fs.statSync calls,
411
+ // same as the bare 204 this replaces. Only a document that actually has
412
+ // `refs.length > 0` pays for the stat loop that follows.
413
+ //
414
+ // Returns the token to hand the client, or null for "nothing stale".
415
+ function checkDrawioStale(fileId, clientId) {
416
+ const state = drawioWatch.get(fileId);
417
+ if (!state || state.refs.length === 0) return null;
418
+ const key = drawioClientKey(fileId, clientId);
419
+ if (key === null) return null;
420
+ const c = drawioClients.get(key);
421
+ // An unknown client here is one touchDrawioClient() declined to
422
+ // (re-)register: a page from before this feature (no clientId at all), a
423
+ // malformed id, or a fileId this server never rendered. It gets the old
424
+ // bare 204 rather than a signal it has no baseline to interpret. Note
425
+ // that hitting DRAWIO_CLIENT_MAX is NOT one of those cases any more —
426
+ // see makeRoomForDrawioClient() (re-review2 H4).
427
+ if (!c) return null;
428
+ const now = new Map();
429
+ let changed = false;
430
+ for (const p of state.refs) {
431
+ const st = drawioLib.drawioStampOf(p);
432
+ now.set(p, st);
433
+ if (c.stamps.get(p) !== st) changed = true;
434
+ }
435
+ if (!changed) { c.pending = null; return null; }
436
+ if (c.pending && sameStamps(c.pending.stamps, now)) return c.pending.id;
437
+ drawioTokenSeq += 1;
438
+ c.pending = { id: 'd' + drawioTokenSeq, stamps: now };
439
+ return c.pending.id;
440
+ }
441
+
442
+ // Keeps a live tab out of the idle sweep above (re-registering it if the
443
+ // sweep already got there — G5), and is the one place the sweep is actually
444
+ // driven from: the heartbeat is the only thing that fires often enough to
445
+ // be worth hanging it on, and it is throttled to at most once a minute
446
+ // inside sweepDrawioClients().
447
+ //
448
+ // ORDER IS LOAD-BEARING: ensure/touch first, sweep second. The other way
449
+ // round, a tab returning from a >10-minute freeze is deleted by the very
450
+ // ping it sent to say it was back.
451
+ function touchDrawioClient(fileId, clientId) {
452
+ const now = Date.now();
453
+ ensureDrawioClient(fileId, clientId, now);
454
+ sweepDrawioClients(now);
455
+ }
456
+
457
+ // The client's half of F2's invariant: it sends back the token it was given
458
+ // ONLY after its DOM really carries that render. Anything less than a
459
+ // complete apply (a block skipped because the user's focus was inside it,
460
+ // a mid-gesture DOM, a failed fetch) sends nothing, so `stamps` stays put
461
+ // and the very next heartbeat reports the same staleness again.
462
+ function ackDrawioApplied(fileId, clientId, token) {
463
+ if (typeof token !== 'string' || token === '') return;
464
+ const key = drawioClientKey(fileId, clientId);
465
+ if (key === null) return;
466
+ const c = drawioClients.get(key);
467
+ if (!c || !c.pending || c.pending.id !== token) return;
468
+ c.stamps = c.pending.stamps;
469
+ c.pending = null;
470
+ }
471
+
153
472
  function bumpIdle(server) {
154
473
  if (!started) return;
155
474
  if (idleTimer) clearTimeout(idleTimer);
156
- idleTimer = setTimeout(() => server.close(), idleTimeoutMs);
475
+ idleTimer = setTimeout(() => {
476
+ shutdownServer(server, 'idle');
477
+ }, idleTimeoutMs);
157
478
  if (idleTimer.unref) idleTimer.unref();
158
479
  }
159
480
 
@@ -235,11 +556,15 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
235
556
  const eol = (crlfCount > bareLf && crlfCount > bareCr) ? '\r\n'
236
557
  : (bareCr > bareLf && bareCr > crlfCount) ? '\r'
237
558
  : '\n';
238
- const { html, blocks } = await renderMarkdown(mdText, file, { editMode: true });
559
+ const { html, blocks, drawioRefs } = await renderMarkdown(mdText, file, { editMode: true });
560
+ // Fix round 1 (review F3): mint this TAB's own staleness baseline from
561
+ // the very render that is about to paint it. Per-tab, not per-file —
562
+ // two tabs on one document must each get told about an external edit.
563
+ const drawioClientId = registerDrawioClient(fileId, trackDrawioRefs(fileId, drawioRefs));
239
564
  const lines = mdText.split(/\r\n|\r|\n/);
240
565
  assertBlockRangesFit(blocks, lines);
241
566
  const payload = JSON.stringify({
242
- fileId, mtimeMs, eol, lines, blocks,
567
+ fileId, mtimeMs, eol, lines, blocks, drawioClientId,
243
568
  });
244
569
  const inject =
245
570
  `<script>window.__ED__ = ${payload.replace(/</g, '\\u003c')}</script>\n` +
@@ -257,6 +582,10 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
257
582
  `<script>${ASSET_SRC}</script>\n` +
258
583
  `<script>${TURNDOWN_SRC}</script>\n` +
259
584
  `<script>${PASTE_MD_SRC}</script>\n` +
585
+ waveModuleTag('wave-codec.js', WAVE_CODEC_SRC) +
586
+ waveModuleTag('wave-geometry.js', WAVE_GEOMETRY_SRC) +
587
+ waveModuleTag('wave-store.js', WAVE_STORE_SRC) +
588
+ `<script>${WAVE_UI_SRC}</script>\n` +
260
589
  `<script>${clientJs}</script>\n`;
261
590
  // Splice at the LAST "</body>" — the document's real closing tag.
262
591
  // The first occurrence can sit inside an inlined diagram bundle's JS
@@ -283,7 +612,10 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
283
612
  const { fileId, content } = await readJson(req);
284
613
  const file = absFiles[fileId];
285
614
  if (!file) return send(res, 404, { error: 'unknown file' });
286
- const { parts, blocks } = await renderMarkdown(content, file, { editMode: true });
615
+ const { parts, blocks, drawioRefs } = await renderMarkdown(content, file, { editMode: true });
616
+ // Refs only — NOT any client's baseline. See trackDrawioRefs()'s own
617
+ // comment: the client has not yet decided whether it can apply this.
618
+ trackDrawioRefs(fileId, drawioRefs);
287
619
  bumpIdle(server);
288
620
  return send(res, 200, { parts, blocks });
289
621
  }
@@ -412,6 +744,40 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
412
744
 
413
745
  if (req.method === 'POST' && url.pathname === '/api/ping') {
414
746
  bumpIdle(server);
747
+ // v3.4.0 batch2 Task 6: fileId / drawioClientId / drawioAck are NEW,
748
+ // all optional, on the body this heartbeat already sent every 10s
749
+ // (old clients / the existing test in test/editor-server.test.js send
750
+ // `{}` — that still resolves to "no tracked state", the same
751
+ // zero-cost 204 as before). A body that fails to parse degrades the
752
+ // same way: no staleness check, plain 204 — a malformed ping body
753
+ // must never be the thing that breaks the idle-keepalive contract.
754
+ //
755
+ // Fix round 1 (review F8): an explicit 8 KB readJson limit, not the
756
+ // 50 MB default — the same precedent /api/save set for its own body,
757
+ // for the same reason. This body is always three short scalars, and
758
+ // this is the ONE route that fires unconditionally every 10 seconds
759
+ // from every open tab; it has no business also being the most
760
+ // permissive one in the server.
761
+ let fileId, drawioClientId, drawioAck;
762
+ try {
763
+ const body = await readJson(req, 8 * 1024);
764
+ if (body) {
765
+ fileId = body.fileId;
766
+ drawioClientId = body.drawioClientId;
767
+ drawioAck = body.drawioAck;
768
+ }
769
+ } catch (e) {
770
+ fileId = undefined; drawioClientId = undefined; drawioAck = undefined;
771
+ }
772
+ // Ack FIRST, then re-check: a client that has just confirmed it
773
+ // applied version N must get a 204 on this same beat unless the file
774
+ // has moved on past N since.
775
+ touchDrawioClient(fileId, drawioClientId);
776
+ ackDrawioApplied(fileId, drawioClientId, drawioAck);
777
+ const staleToken = checkDrawioStale(fileId, drawioClientId);
778
+ if (staleToken) {
779
+ return send(res, 200, { stale: true, token: staleToken });
780
+ }
415
781
  res.writeHead(204);
416
782
  return res.end();
417
783
  }
@@ -442,6 +808,28 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
442
808
  server.listen(listenPort, '127.0.0.1');
443
809
  });
444
810
  const port = server.address().port;
811
+
812
+ // F13: counts from listen (this point), not from process start — listen
813
+ // is the moment a client could first reach the server, and that is the
814
+ // deadline this timer is measuring against. Unref'd like idleTimer, and
815
+ // for the same reason: the listening socket is what should keep the
816
+ // process alive, not this timer. A timer that held a ref would keep the
817
+ // process up on its own even if the socket already went away, which is
818
+ // backwards for a clock whose only job is to give up and exit.
819
+ neverOpenedTimer = setTimeout(() => {
820
+ // The guard against closing a session that has already been opened —
821
+ // this timer is armed once, at listen time, and left to run; it is
822
+ // not cancelled by the GET /edit/:id handler. Whether the URL was
823
+ // opened by the time the deadline arrives is decided here, by reading
824
+ // `started` fresh at fire time. Proven load-bearing by mutation: with
825
+ // this line removed, an actively-pinged session still gets closed the
826
+ // moment this deadline passes (see test/cli-edit.test.js's "must
827
+ // survive the never-opened grace deadline" case).
828
+ if (started) return;
829
+ shutdownServer(server, 'never-opened');
830
+ }, neverOpenedGraceMs);
831
+ if (neverOpenedTimer.unref) neverOpenedTimer.unref();
832
+
445
833
  return {
446
834
  server,
447
835
  port,
@@ -449,7 +837,11 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
449
837
  const i = absFiles.indexOf(path.resolve(absPath));
450
838
  return i === -1 ? null : `http://127.0.0.1:${port}/edit/${i}`;
451
839
  },
452
- close() { if (idleTimer) clearTimeout(idleTimer); server.close(); },
840
+ close() {
841
+ if (idleTimer) clearTimeout(idleTimer);
842
+ if (neverOpenedTimer) clearTimeout(neverOpenedTimer);
843
+ shutdownServer(server, null);
844
+ },
453
845
  };
454
846
  }
455
847