Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ follows [Semantic Versioning](https://semver.org).

---

## [3.1.3] β€” 2026-10-22

### Fixed

- **A `dune serve` process never exited after a graceful shutdown.** The
collab manager's session-eviction `setInterval` (every 5 minutes) was never
`unref()`'d, so after `dune serve` received SIGTERM, closed its listener and
printed `shutdown complete`, this one timer kept the process alive forever
at 0% CPU β€” an idle dune process holding nothing but a PID. (A second
SIGTERM ended it.) The timer is now unref'd, so it can never keep the
process alive. Found by listing the live timers after shutdown: it was the
only one.

## [3.1.2] β€” 2026-10-15

### Security
Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@dune/plugin-admin",
"version": "3.1.2",
"version": "3.1.3",
"license": "MIT",
"minimumDependencyAge": {
"age": "P1D",
Expand Down
6 changes: 5 additions & 1 deletion src/collab/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,18 @@ export function createCollabManager(options: CollabManagerOptions): CollabManage
const connectionsPerUser = new Map<string, number>();

// ── Session eviction timer (every 5 minutes) ───────────────────────────────
const _evictionTimer = setInterval(() => {
const evictionTimer = setInterval(() => {
const now = Date.now();
for (const [docId, session] of sessions) {
if (session.clients.size === 0 && now - session.lastActivity > 5 * 60_000) {
sessions.delete(docId);
}
}
}, 5 * 60_000);
// Housekeeping must never keep the process alive. As a ref'd interval this
// outlived `dune serve`'s graceful shutdown (listener closed, "shutdown
// complete" printed) and the process then never exited.
Deno.unrefTimer(evictionTimer);

// ── Load / get session ─────────────────────────────────────────────────────

Expand Down
56 changes: 56 additions & 0 deletions tests/collab/eviction_timer_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* The collab manager's session-eviction interval must not keep the process
* alive. It used to be a ref'd 5-minute `setInterval`, so after `dune serve`
* finished its graceful shutdown (listener closed, "shutdown complete"
* printed) the process never exited: idle dune processes piled up.
*
* Runs in a subprocess because "does the process exit on its own?" is exactly
* the property under test; an in-process check can't see a ref'd timer.
*/

import { assertEquals } from "jsr:@std/assert@^1";
import { join, toFileUrl } from "jsr:@std/path@^1";

const REPO = join(import.meta.dirname!, "..", "..");

Deno.test({
name: "createCollabManager: creating one does not keep the process alive",
sanitizeOps: false,
sanitizeResources: false,
fn: async () => {
const dir = await Deno.makeTempDir({ prefix: "plugin_admin_collab_timer_" });
try {
const script = join(dir, "create.ts");
await Deno.writeTextFile(
script,
`import { createCollabManager } from ${JSON.stringify(toFileUrl(join(REPO, "src", "collab", "mod.ts")).href)};\n` +
`// Stubs are fine: nothing touches them until a client connects.\n` +
`createCollabManager({ storage: {} as never, engine: {} as never, history: {} as never, contentDir: "content" });\n` +
`console.log("created");\n`,
);

const child = new Deno.Command(Deno.execPath(), {
args: ["run", "-A", `--config=${join(REPO, "deno.json")}`, script],
stdout: "piped",
stderr: "piped",
}).spawn();

// Generous: a cold dependency cache can make the first run slow. The
// regression is a hang of *minutes*, so any finite wait tells them apart.
const timer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch { /* already exited */ }
}, 60_000);
const out = await child.output();
clearTimeout(timer);

const stdout = new TextDecoder().decode(out.stdout);
assertEquals(stdout.includes("created"), true, new TextDecoder().decode(out.stderr));
assertEquals(out.success, true, "process should exit on its own once main finishes; a ref'd interval keeps it alive");
assertEquals(out.signal, null, "process was killed by the test's timeout, meaning it never exited on its own");
} finally {
await Deno.remove(dir, { recursive: true }).catch(() => {});
}
},
});
Loading