← Blog

Claude skill resource cleanup and lifecycle security

Claude skills run as long-lived Node.js processes that handle many tool calls over their lifetime. When a skill opens file handles, database connections, network sockets, or stream objects without reliably closing them on all exit paths — including error paths and Promise rejections — those resources accumulate silently until the process hits OS limits or runs out of memory, causing a denial-of-service crash for all users of that skill. Four patterns define the SA-SKL-RCL family: file handles not closed on error, database connection pool exhaustion, missing finally cleanup on async operations, and readline/stream objects that accumulate across tool calls.

By SkillAudit  |  31 Oct 2026

Resource leakage vulnerabilities in Claude skills are distinct from classic server resource leaks because skills are typically single-user processes running on a developer’s machine or a small shared host with strict per-process file descriptor limits. A skill that leaks one file handle per tool call will exhaust the default Linux limit of 1024 open file descriptors in 1024 tool calls — a quantity easily reached in an active Claude Code session. In hosted deployments where a single skill process serves multiple users, the limit is reached faster and the crash affects all concurrent users. The SA-SKL-RCL family documents four common patterns that cause resource accumulation.

SA-SKL-RCL-001: file handles opened in tool handlers not closed on error paths

Node.js’s fs.open, fs.createReadStream, and fs.createWriteStream return file descriptors or stream objects that hold OS file handles until explicitly closed. Skills that open file handles inside tool handlers and close them only on the success path leave handles open forever when an error occurs — which an attacker can trigger reliably by supplying invalid input, a non-existent path, or a file that causes a processing error. After enough calls, the process exhausts its file descriptor limit and all subsequent operations that open files (including log writes, SQLite reads, and HTTP connection attempts) fail with EMFILE: too many open files.

// VULNERABLE: file handle left open on error path
import { promises as fs } from 'fs';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'read_file') {
    const { path } = req.params.arguments;
    const fileHandle = await fs.open(path, 'r');

    // Error here: fileHandle is never closed
    const content = await parseComplexFormat(await fileHandle.readFile());
    // If parseComplexFormat() throws, fileHandle leaks
    await fileHandle.close();
    return { content: [{ type: 'text', text: content }] };
  }
});

// ALSO VULNERABLE: ReadStream not destroyed on error
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'stream_file') {
    const { path } = req.params.arguments;
    const stream = fs.createReadStream(path);
    // stream is not destroyed if processing throws
    const result = await processStream(stream);
    return { content: [{ type: 'text', text: result }] };
  }
});

// SECURE: use try/finally to guarantee cleanup
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'read_file') {
    const { path } = req.params.arguments;
    const fileHandle = await fs.open(path, 'r');
    try {
      const content = await parseComplexFormat(await fileHandle.readFile());
      return { content: [{ type: 'text', text: content }] };
    } finally {
      await fileHandle.close();  // always runs: success, error, or cancellation
    }
  }
});

// ALSO SECURE: use using keyword (Node.js 18+ with --experimental-vm-modules)
// or fs.readFile() / fs.writeFile() which manage handles internally
import { readFile } from 'fs/promises';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'read_file') {
    const { path } = req.params.arguments;
    // readFile opens, reads, and closes the handle atomically — no leak possible
    const content = await readFile(path, 'utf8');
    return { content: [{ type: 'text', text: content }] };
  }
});

// DETECTION (SkillAudit):
// Find fs.open() calls in async functions without a try/finally wrapping fileHandle.close()
const leakyOpens = findFileOpenWithoutFinallyClose();
if (leakyOpens.length > 0) → HIGH  // SA-SKL-RCL-001
// Find createReadStream/createWriteStream without .destroy() in finally or pipeline error handler
const leakyStreams = findStreamCreationWithoutDestroyOnError();
if (leakyStreams.length > 0) → HIGH  // SA-SKL-RCL-001

Node.js does NOT close file handles when a Promise rejects: unlike languages with native RAII or using-declaration support, JavaScript file handles are not garbage-collected. An async function that throws before calling close() leaves the handle permanently open in the process. The only safe pattern is try { ... } finally { close() } or using high-level functions (readFile, writeFile) that manage handle lifecycle internally.

HIGH
SA-SKL-RCL-001 — fs.open() or createReadStream/WriteStream in tool handlers without try/finally guarantee of close()/destroy(): each error-triggered call leaks a file descriptor until the process hits EMFILE and crashes. Detection: fs.open without finally-scoped close() → HIGH.

SA-SKL-RCL-002: database connection pool exhaustion via un-released connections

Skills that use database connection pools (better-sqlite3, pg, mysql2, knex) acquire a connection from the pool for each query and must release it when done. In synchronous SQLite code this is implicit, but in async pool-based drivers, a connection acquired with pool.connect() must be explicitly released with client.release(). If a tool handler acquires a connection, performs a query that throws, and the exception propagates without a finally block that releases the connection, the connection is permanently held. Once all pool connections are held by leaked handlers, every subsequent database call blocks indefinitely, effectively hanging the entire skill process for all users.

// VULNERABLE: pg pool connection not released on error
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'query_db') {
    const { sql } = req.params.arguments;
    const client = await pool.connect();  // acquires connection from pool

    // If this throws, client.release() never called — connection leaked
    const result = await client.query(sql);
    client.release();

    return { content: [{ type: 'text', text: JSON.stringify(result.rows) }] };
  }
});

// ALSO VULNERABLE: multiple release paths — easy to miss one
async function runQuery(client, sql) {
  try {
    return await client.query(sql);
  } catch (e) {
    // Error branch: forgot to release before re-throwing
    throw e;  // ← connection leaked on every error
  }
  // Success path: caller releases — but error path does not
}

// SECURE: always release in finally
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'query_db') {
    const { sql } = req.params.arguments;
    const client = await pool.connect();
    try {
      const result = await client.query(sql);
      return { content: [{ type: 'text', text: JSON.stringify(result.rows) }] };
    } finally {
      client.release();  // runs on success, error, and Promise cancellation
    }
  }
});

// ALSO SECURE: use pool.query() for single-statement queries — handles release internally
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'query_db') {
    const { sql } = req.params.arguments;
    // pool.query() acquires, queries, and releases the connection atomically
    const result = await pool.query(sql);
    return { content: [{ type: 'text', text: JSON.stringify(result.rows) }] };
  }
});

// DETECTION (SkillAudit):
// Find pool.connect() without a finally block that calls client.release()
const leakyConnects = findPoolConnectWithoutFinallyRelease();
if (leakyConnects.length > 0) → HIGH  // SA-SKL-RCL-002
// Find patterns where client.release() is called conditionally or in try body only
const conditionalRelease = findConditionalRelease();
if (conditionalRelease.length > 0) → HIGH  // SA-SKL-RCL-002

Pool exhaustion is a reliable DoS trigger: an attacker who can cause tool handler exceptions (via invalid arguments, non-existent files, or network partition) can exhaust a connection pool in as many calls as there are pool connections (typically 5-20). Once the pool is exhausted, all subsequent tool calls that need a database connection hang indefinitely until the MCP host times them out. In Claude Code, this manifests as the skill silently not responding to any further invocations.

HIGH
SA-SKL-RCL-002 — database pool.connect() without finally { client.release() }: each error path leaks a connection until the pool is exhausted and all subsequent DB operations hang indefinitely. Detection: pool.connect() without finally-scoped release() → HIGH.

SA-SKL-RCL-003: Promise rejection without cleanup leaves resources open indefinitely

Async tool handlers often set up multiple resources in sequence: open a temp file, acquire a DB connection, create an HTTP client, start a child process. When an intermediate step rejects, earlier resources that were successfully opened must still be cleaned up. Without explicit cleanup in a finally block, any resources acquired before the rejection point leak for the lifetime of the process. A common manifestation is a try/catch that handles the error for user-facing purposes but forgets to clean up resources acquired before the error, or an async function that creates resources at the top level and tries to clean them up only at the bottom — leaving them open if any operation in between rejects.

// VULNERABLE: resources at top level with cleanup only on success path
import { promises as fs } from 'fs';
import { createConnection } from 'mysql2/promise';
import { tmpdir } from 'os';
import { join } from 'path';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'export_data') {
    const { userId } = req.params.arguments;

    const tmpFile = join(tmpdir(), `export-${Date.now()}.csv`);
    const handle = await fs.open(tmpFile, 'w');  // resource 1
    const conn = await createConnection(process.env.DATABASE_URL);  // resource 2

    // If any of these throw: both resources leak
    const rows = await conn.query('SELECT * FROM users WHERE id = ?', [userId]);
    const csv = toCSV(rows[0]);
    await handle.writeFile(csv);  // if this throws, conn AND handle leak

    await handle.close();  // only reached on full success
    await conn.end();       // only reached on full success

    return { content: [{ type: 'text', text: `Exported ${rows[0].length} rows` }] };
  }
});

// SECURE: nested try/finally for each resource in acquisition order
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'export_data') {
    const { userId } = req.params.arguments;

    const tmpFile = join(tmpdir(), `export-${Date.now()}.csv`);
    const handle = await fs.open(tmpFile, 'w');
    try {
      const conn = await createConnection(process.env.DATABASE_URL);
      try {
        const [rows] = await conn.query('SELECT * FROM users WHERE id = ?', [userId]);
        await handle.writeFile(toCSV(rows));
        return { content: [{ type: 'text', text: `Exported ${rows.length} rows` }] };
      } finally {
        await conn.end();  // always releases DB connection
      }
    } finally {
      await handle.close();  // always closes temp file
    }
  }
});

// ALSO SECURE: AsyncDisposable pattern (Node.js 20+ / TypeScript 5.2+)
// using declarations automatically call [Symbol.asyncDispose]() on scope exit

// DETECTION (SkillAudit):
// Find async functions that acquire multiple resources with cleanup not in finally blocks
const multiResourceLeak = findMultiResourceAcquireWithoutNestedFinally();
if (multiResourceLeak.length > 0) → HIGH  // SA-SKL-RCL-003
// Find try/catch blocks with resource cleanup in catch but not in finally
const cleanupOnlyCatch = findResourceCleanupOnlyInCatch();
if (cleanupOnlyCatch.length > 0) → MEDIUM  // SA-SKL-RCL-003 (catch swallows errors)

Nested finally blocks are the safe pattern for multiple resources: each resource acquired gets its own try/finally wrap, with later acquisitions nested inside earlier ones. This ensures that if resource 2 acquisition fails, resource 1 is still cleaned up; if the operation fails after acquiring both, both are cleaned up. The nesting order mirrors acquisition order exactly.

HIGH
SA-SKL-RCL-003 — multiple resource acquisitions in tool handler without nested try/finally for each: any rejection between first and last acquisition leaks all acquired-but-not-yet-cleaned-up resources permanently. Detection: sequential await open/connect/create calls without matching finally blocks → HIGH.

SA-SKL-RCL-004: readline and stream objects accumulate across tool calls

Skills that process text input line-by-line using Node.js’s readline module, or that pipeline data through Transform streams, must close or destroy those objects after use. Unlike file handles, stream objects are JavaScript objects managed by the GC — but they hold references to the underlying input stream, preventing it from being collected. readline.Interface objects additionally hold process.stdin references that keep the event loop alive and emit close events that can interfere with subsequent tool calls. A skill that creates a new readline.Interface or Transform stream for each tool invocation without calling rl.close() or stream.destroy() accumulates these objects across hundreds of calls, causing memory exhaustion and event loop pollution that degrades skill responsiveness until it crashes.

// VULNERABLE: readline.Interface created per tool call, never closed
import { createInterface } from 'readline';
import { Readable } from 'stream';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'parse_text') {
    const { content } = req.params.arguments;

    const rl = createInterface({
      input: Readable.from([content]),
      crlfDelay: Infinity
    });
    // rl is never closed — accumulates across calls
    // After 1000 calls: 1000 open readline interfaces, significant memory pressure

    const lines = [];
    for await (const line of rl) {
      lines.push(line.trim());
    }
    // rl.close() not called — interface lingers until GC (indeterminate)
    return { content: [{ type: 'text', text: lines.join('\n') }] };
  }
});

// ALSO VULNERABLE: Transform streams created per call without destroy
import { Transform } from 'stream';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'transform_data') {
    const { data } = req.params.arguments;

    const transform = new Transform({
      transform(chunk, encoding, callback) {
        callback(null, processChunk(chunk));
      }
    });
    // Transform not destroyed after use — event listeners accumulate
    const result = await streamToString(transform);
    return { content: [{ type: 'text', text: result }] };
  }
});

// SECURE: close readline.Interface in finally
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'parse_text') {
    const { content } = req.params.arguments;

    const rl = createInterface({
      input: Readable.from([content]),
      crlfDelay: Infinity
    });
    try {
      const lines = [];
      for await (const line of rl) {
        lines.push(line.trim());
      }
      return { content: [{ type: 'text', text: lines.join('\n') }] };
    } finally {
      rl.close();  // releases input stream reference and removes event listeners
    }
  }
});

// ALSO SECURE: use string methods or Buffer operations instead of streams for small content
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'parse_text') {
    const { content } = req.params.arguments;
    // No stream allocation — just string operations
    const lines = content.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
    return { content: [{ type: 'text', text: lines.join('\n') }] };
  }
});

// DETECTION (SkillAudit):
// Find readline.createInterface() in tool handler functions without rl.close() in finally
const leakyRL = findReadlineCreateWithoutClose();
if (leakyRL.length > 0) → HIGH  // SA-SKL-RCL-004
// Find new Transform/Duplex/PassThrough stream creation without .destroy() in finally
const leakyTransforms = findStreamCreationWithoutDestroyInFinally();
if (leakyTransforms.length > 0) → MEDIUM  // SA-SKL-RCL-004

The for await...of loop on a readline interface does not auto-close it: the async iterator protocol terminates the loop when the stream ends, but it does not call rl.close(). The readline interface remains open with all its event listeners until explicitly closed or until the underlying stream emits a close event. Always wrap for await (const line of rl) in try/finally { rl.close() }.

HIGH
SA-SKL-RCL-004 — readline.createInterface() or stream creation per tool call without rl.close()/stream.destroy() in finally: objects accumulate across hundreds of calls causing memory pressure, event loop pollution, and eventual OOM crash. Detection: createInterface without finally-scoped close() → HIGH.

SA-SKL-RCL summary

IDPatternSeverityImpact
SA-SKL-RCL-001File handles from fs.open()/createReadStream not closed on errorHIGHEMFILE crash after ~1024 error-triggered calls; all operations fail
SA-SKL-RCL-002DB connection pool connect() without finally { release() }HIGHPool exhaustion; all DB queries hang indefinitely after ~10-20 errors
SA-SKL-RCL-003Multiple resource acquisitions without nested finally per resourceHIGHPartial resource leak on each error path; accumulates silently to OOM
SA-SKL-RCL-004readline or Transform streams per call without close()/destroy()HIGHEvent listener accumulation; memory exhaustion in long-running skill sessions

How SkillAudit detects SA-SKL-RCL patterns

SkillAudit’s static analysis phase performs resource-pairing checks: for every acquisition call (fs.open, pool.connect, createInterface, createReadStream), it verifies that a corresponding release call (close, release, destroy) exists in a finally block that is reachable regardless of whether the enclosed code throws. Calls where the release is only in the try body, only in a catch, or conditional on a variable are flagged as leaky. The checker also identifies sequential resource acquisitions at the top level of async functions and verifies they are wrapped in nested finally blocks ordered to match acquisition order.

Run a free SkillAudit scan to detect all four SA-SKL-RCL resource leak patterns alongside SA-SKL-ENV credential exposure, SA-SKL-SBX sandbox escape, SA-SKL-MEM state leakage, and SA-SKL-NET undisclosed egress.