<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Secure Defaults Archives - Linuxcent</title>
	<atom:link href="https://linuxcent.com/tag/secure-defaults/feed/" rel="self" type="application/rss+xml" />
	<link>https://linuxcent.com/tag/secure-defaults/</link>
	<description>Infrastructure security, from the kernel up.</description>
	<lastBuildDate>Fri, 31 Jul 2026 07:04:00 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.4</generator>

<image>
	<url>https://linuxcent.com/wp-content/uploads/2026/04/favicon-512x512-1-150x150.png</url>
	<title>Secure Defaults Archives - Linuxcent</title>
	<link>https://linuxcent.com/tag/secure-defaults/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">211632295</site>	<item>
		<title>Exit Code 0 Lied: The Silent Node.js Bug That Hit sarvam-cli Three Times</title>
		<link>https://linuxcent.com/nodejs-readline-silent-exit/</link>
					<comments>https://linuxcent.com/nodejs-readline-silent-exit/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Fri, 31 Jul 2026 06:54:45 +0000</pubDate>
				<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[AI Agents]]></category>
		<category><![CDATA[CLI]]></category>
		<category><![CDATA[Debugging]]></category>
		<category><![CDATA[Node.js]]></category>
		<category><![CDATA[Open Source]]></category>
		<category><![CDATA[readline]]></category>
		<category><![CDATA[sarvam-cli]]></category>
		<category><![CDATA[Secure Defaults]]></category>
		<category><![CDATA[Software Quality]]></category>
		<category><![CDATA[TypeScript]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=2295</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span>Fix the Node.js readline silent exit: why rl.question() never resolves when stdin closes, and how to make your CLI fail closed instead of exit 0.</p>
<p>The post <a href="https://linuxcent.com/nodejs-readline-silent-exit/">Exit Code 0 Lied: The Silent Node.js Bug That Hit sarvam-cli Three Times</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 9</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;<br />
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;<br />
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;<br />
    font-size:.88em;line-height:1.6;border-left:4px solid #555}<br />
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}<br />
pre code{background:transparent;padding:0;color:inherit}<br />
pre[data-lang="bash"],pre[data-lang="sh"],<br />
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}<br />
pre[data-lang="yaml"],pre[data-lang="json"],<br />
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}<br />
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],<br />
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}<br />
pre[data-lang="text"],pre[data-lang="output"],<br />
pre[data-lang="console"]{border-left-color:#888}<br />
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;<br />
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;<br />
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;<br />
    transition:opacity .15s,background .15s;line-height:1.6}<br />
pre:hover .lc-copy-btn{opacity:1}<br />
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}<br />
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}<br />
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;<br />
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;<br />
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}<br />
pre:hover .lc-lang-badge{opacity:1}<br />
table{border-collapse:collapse;width:100%;margin:16px 0}<br />
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}<br />
th{background:#f0f0f0;font-weight:600}<br />
tr:nth-child(even){background:#fafafa}<br />
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>11 min read</em></p>
<p>A Node.js readline silent exit happens when <code class="" data-line="">rl.question()</code> never fires its callback. The interface closes first, the awaited promise stays pending forever, and the event loop drains. As a result, the process exits cleanly with status 0 — mid-prompt, no error, no stack trace. I hit this in three separate places in <a href="https://github.com/indic-ai-contribs/sarvam-cli">sarvam-cli</a>, an MIT-licensed agentic coding CLI. The fix is eight lines. The interesting part is what each call site should do when input ends, and why &#8220;decline&#8221; is the only safe answer at an approval prompt.</p>
<h2 id="table-of-contents">Table of Contents</h2>
<ul>
<li><a href="#tldr">TL;DR</a></li>
<li><a href="#the-symptom-a-at-the-end-of-a-terminal-paste">The Symptom: a % at the End of a Terminal Paste</a></li>
<li><a href="#why-my-first-diagnosis-was-wrong">Why My First Diagnosis Was Wrong</a></li>
<li><a href="#what-actually-causes-the-nodejs-readline-silent-exit">What Actually Causes the Node.js readline Silent Exit</a></li>
<li><a href="#why-exit-code-0-is-the-real-damage">Why Exit Code 0 Is the Real Damage</a></li>
<li><a href="#three-call-sites-one-copy-pasted-habit">Three Call Sites, One Copy-Pasted Habit</a></li>
<li><a href="#the-fix-resolve-to-a-sentinel-when-the-interface-closes">The Fix: Resolve to a Sentinel When the Interface Closes</a></li>
<li><a href="#what-null-should-mean-at-each-call-site">What null Should Mean at Each Call Site</a></li>
<li><a href="#why-a-partial-write-is-worse-than-no-write">Why a Partial Write Is Worse Than No Write</a></li>
<li><a href="#proving-it-testing-the-interactive-path-in-a-pty">Proving It: Testing the Interactive Path in a pty</a></li>
<li><a href="#quick-reference">Quick Reference</a></li>
<li><a href="#beyond-node-any-callback-with-a-path-that-never-runs">Beyond Node: Any Callback With a Path That Never Runs</a></li>
<li><a href="#cissp-domain-mapping">CISSP Domain Mapping</a></li>
<li><a href="#key-takeaways">Key Takeaways</a></li>
<li><a href="#try-sarvam-cli">Try sarvam-cli</a></li>
</ul>
<h2 id="tldr">TL;DR</h2>
<ul>
<li><code class="" data-line="">rl.question()</code> never fires its callback if the readline interface closes first. Ctrl+D, Ctrl+C, and exhausted piped stdin all close it.</li>
<li>A pending promise is not a running task. Node finds nothing scheduled, drains the loop, and exits with status 0 — so the failure presents as success.</li>
<li>The fix is to resolve to <code class="" data-line="">null</code> on close, not to reject. End of input is normal, not exceptional.</li>
<li>What <code class="" data-line="">null</code> <em>means</em> differs per call site: exit at a REPL prompt, <strong>decline</strong> at a consent gate, abort without writing in a config wizard.</li>
<li>At an approval prompt, <code class="" data-line="">=== &quot;y&quot;</code> fails closed and <code class="" data-line="">!== &quot;n&quot;</code> fails open. Same line count, opposite blast radius.</li>
<li>Test the interactive path in a pseudo-terminal. Piping only exercises the non-TTY code path, and your users are on the other one.</li>
</ul>
<h2 id="the-symptom-a-at-the-end-of-a-terminal-paste">The Symptom: a % at the End of a Terminal Paste</h2>
<p>Someone sent me a session transcript from sarvam-cli. The last four lines:</p>
<pre><code class="" data-line="">❯ /model
Current model: sarvam-105b
Available: sarvam-105b
model&gt; %
</code></pre>
<p>That trailing <code class="" data-line="">%</code> is zsh telling you the previous command produced output with no final newline. Specifically, it only appears when zsh has <em>regained control</em> — which means the process exited. While sitting at a prompt. Having printed <code class="" data-line="">model&gt;</code> and then simply stopped existing.</p>
<p>No error. No traceback. Nothing in the logs.</p>
<h2 id="why-my-first-diagnosis-was-wrong">Why My First Diagnosis Was Wrong</h2>
<p>My first theory was wrong, and it is worth saying so. I assumed stdin contention. The code attached a raw <code class="" data-line="">process.stdin.on(&quot;data&quot;)</code> listener for a Ctrl+O keybinding <em>while</em> a <code class="" data-line="">readline</code> interface was consuming the same stream. Two readers, one pipe — a classic. I wrote it up confidently.</p>
<p>Then I reproduced it before fixing it, and the theory collapsed. The <code class="" data-line="">/model</code> flow completed perfectly. Additionally, the line buffer survived a mid-line keypress intact. Whatever killed the process, it was not stdin contention.</p>
<h2 id="what-actually-causes-the-nodejs-readline-silent-exit">What Actually Causes the Node.js readline Silent Exit</h2>
<p>Here is the code every Node CLI writes to get an async prompt:</p>
<pre><code class="" data-line="">const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q) =&gt; new Promise((resolve) =&gt; rl.question(q, (a) =&gt; resolve(a)));

const name = await ask(&quot;Your name: &quot;);
</code></pre>
<p><code class="" data-line="">rl.question()</code> registers a <strong>one-shot callback</strong> for the next line of input. That is the whole contract. Consequently, there is exactly one way for it to never be called: the interface closes before a line arrives.</p>
<p>Three ordinary things close it:</p>
<ul>
<li><strong>Ctrl+D</strong> — EOF at the terminal</li>
<li><strong>Ctrl+C</strong> — readline&#8217;s default SIGINT behaviour closes the interface</li>
<li><strong>Piped stdin running out of lines</strong> — <code class="" data-line="">printf &#039;a\nb\n&#039; | sarvam</code> when the CLI asks five questions</li>
</ul>
<p>When any of those happen, the callback is dropped. The promise attached to it stays pending. <code class="" data-line="">await</code> never returns.</p>
<p>Now the part that turns a bug into a <em>silent</em> bug. A pending promise is not a running task. Node does not wait on it, because nothing is scheduled — no timer, no socket, no handle. The event loop finds itself with nothing to do, and does the correct thing:</p>
<p><strong>It exits. Status 0.</strong></p>
<p>From the outside, your program reported success. From the inside, it never finished the line it was on.</p>
<pre><code class="" data-line="">$ printf &#039;\n\n\n&#039; | sarvam --init
  sarvam init
  ----------------

Provider [a/b] (default: a): API key: $ echo $?
0
</code></pre>
<p>Success. Nothing written.</p>
<h2 id="why-exit-code-0-is-the-real-damage">Why Exit Code 0 Is the Real Damage</h2>
<p>A crash is loud. You get a stack trace, a non-zero status, and CI goes red. This is the opposite. It is a <strong>false success</strong>, and false success is the failure mode that survives longest in production, because nothing is watching for it.</p>
<p>That gap between &#8220;the process succeeded&#8221; and &#8220;the product worked&#8221; is exactly the <a href="https://linuxcent.com/product-quality-vs-code-quality/">product quality vs code quality</a> split — no test suite in this repo was wrong, and every one of them passed.</p>
<p>Concretely, in my case:</p>
<pre><code class="" data-line="">sarvam --init &amp;&amp; echo &quot;configured&quot; &amp;&amp; deploy.sh
</code></pre>
<p><code class="" data-line="">--init</code> exits 0 having written no config file. However, the <code class="" data-line="">&amp;&amp;</code> chain proceeds anyway. Then <code class="" data-line="">deploy.sh</code> runs against a machine that was never configured. The one signal a shell script has for &#8220;did this work&#8221; was lying.</p>
<h2 id="three-call-sites-one-copy-pasted-habit">Three Call Sites, One Copy-Pasted Habit</h2>
<p>Once I understood the mechanism, I grepped for the shape rather than the symptom — <code class="" data-line="">rl.question</code> wrapped in a <code class="" data-line="">new Promise</code>. It appeared three times.</p>
<p><strong>1. The main REPL prompt.</strong> Ctrl+D exited silently. In practice, this is the one everyone would eventually notice and shrug at, because &#8220;Ctrl+D quits&#8221; looks like intended behaviour when the process disappears.</p>
<p><strong>2. A nested sub-prompt.</strong> The one in the transcript. Identical cause, more visible, because it left a half-drawn prompt on screen.</p>
<p><strong>3. The <code class="" data-line="">--init</code> configuration wizard.</strong> The worst of the three, and the one nobody would have found by hand. Specifically, it only misbehaves under piped stdin — which is exactly how CI and setup scripts drive it.</p>
<p>Same eight lines, copy-pasted, three times. That is the honest reason it appeared three times: not three bugs, one habit.</p>
<h2 id="the-fix-resolve-to-a-sentinel-when-the-interface-closes">The Fix: Resolve to a Sentinel When the Interface Closes</h2>
<p>Resolve to <code class="" data-line="">null</code> when the interface closes, so every caller gets a value instead of hanging:</p>
<pre><code class="" data-line="">let closed = false;
rl.on(&quot;close&quot;, () =&gt; {
  closed = true;
});

const ask = (q: string): Promise&lt;string | null&gt; =&gt;
  new Promise((resolve) =&gt; {
    if (closed) return resolve(null);

    let answered = false;
    const onClose = () =&gt; {
      if (!answered) {
        answered = true;
        resolve(null);
      }
    };

    rl.once(&quot;close&quot;, onClose);
    rl.question(q, (a) =&gt; {
      answered = true;
      rl.removeListener(&quot;close&quot;, onClose);
      resolve(a);
    });
  });
</code></pre>
<p>Three details matter more than they look:</p>
<ul>
<li><strong><code class="" data-line="">resolve(null)</code>, not <code class="" data-line="">reject()</code>.</strong> End of input is not exceptional. It is the normal way a pipe finishes and the normal way a user quits. Rejecting forces every call site into a <code class="" data-line="">try/catch</code> and tempts people to swallow it.</li>
<li><strong>The <code class="" data-line="">answered</code> guard.</strong> Without it, a close event firing after a legitimate answer double-resolves. That is harmless with promises, but it hides ordering bugs.</li>
<li><strong><code class="" data-line="">removeListener</code> on the success path.</strong> A long REPL session asks hundreds of questions. Without this you accumulate a close listener per prompt, and Node starts warning you about a leak around 11.</li>
</ul>
<h2 id="what-null-should-mean-at-each-call-site">What null Should Mean at Each Call Site</h2>
<p>This is where a mechanical fix becomes a design decision. <code class="" data-line="">null</code> means &#8220;input ended.&#8221; What you should <em>do</em> about that differs at every call site. Furthermore, getting it wrong at one of them is a security bug.</p>
<p><strong>At the main prompt — exit cleanly.</strong></p>
<pre><code class="" data-line="">const input = await ask(&quot;❯ &quot;);
if (input === null) break;   // fall through to the normal shutdown path
</code></pre>
<p><strong>At an approval prompt — decline. Always.</strong></p>
<pre><code class="" data-line="">const ans = await ask(`▸ ${tool}: ${summary} [y/N] `);
if (ans === null) return false;  // stdin closed — never assume consent
return ans.toLowerCase().trim() === &quot;y&quot;;
</code></pre>
<p>This is the one that matters. sarvam-cli is an agentic assistant that can run shell commands and write files, gated behind a <code class="" data-line="">[y/N]</code> prompt. Therefore, &#8220;input ended&#8221; must never be read as approval.</p>
<p>Note how easily it goes the other way. Had the original been written as a denial check:</p>
<pre><code class="" data-line="">return ans.toLowerCase().trim() !== &quot;n&quot;;   // looks equivalent. isn&#039;t.
</code></pre>
<p>…then an empty or absent answer becomes <strong>approval</strong>, and a closed stdin auto-approves every pending action. Same number of lines, opposite blast radius. Fail-closed is not a philosophy here. It is a comparison operator — and it is the most concrete example of <a href="https://linuxcent.com/cybersecurity-architecture-principles/">cybersecurity architecture principles</a> I have shipped in one line of code.</p>
<h2 id="why-a-partial-write-is-worse-than-no-write">Why a Partial Write Is Worse Than No Write</h2>
<p><strong>In the config wizard — abort without writing.</strong></p>
<pre><code class="" data-line="">const answers: string[] = [];
for (const q of questions) {
  const a = await ask(q);
  if (a === null) {
    rl.close();
    console.error(&quot;\n  init aborted — input ended before every question was answered.&quot;);
    console.error(`  Nothing written to ${CONFIG_PATH}.\n`);
    return null;   // caller exits non-zero
  }
  answers.push(a);
}
</code></pre>
<p>The tempting alternative is to save whatever you collected. Don&#8217;t. In this codebase a partial config with an empty <code class="" data-line="">apiKey</code> field is <em>worse than no file at all</em>, because of a second bug it interacts with:</p>
<pre><code class="" data-line="">// The config file wins over the environment...
const apiKey = file.apiKey ?? process.env.API_KEY ?? &quot;&quot;;
</code></pre>
<p><code class="" data-line="">??</code> only falls through on <code class="" data-line="">null</code>/<code class="" data-line="">undefined</code>. An empty string is a defined value. As a result, a config file containing <code class="" data-line="">&quot;apiKey&quot;: &quot;&quot;</code> <strong>permanently shadows the environment variable</strong>. Export <code class="" data-line="">API_KEY</code> all you like — the CLI reports &#8220;no API key found&#8221; forever, and the file causing it looks empty and harmless.</p>
<p>A partial write turns a clean failure into a persistent one. No write beats a bad write.</p>
<p>On the caller side, actually surface it:</p>
<pre><code class="" data-line="">if (args.init) {
  const created = await initConfigInteractive();
  process.exit(created ? 0 : 1);
}
</code></pre>
<h2 id="proving-it-testing-the-interactive-path-in-a-pty">Proving It: Testing the Interactive Path in a pty</h2>
<p>You cannot test this properly with a pipe. <code class="" data-line="">readline</code> behaves differently when stdin is not a TTY, so piping only exercises one of the two paths — and the interactive path is the one your users are on.</p>
<p>The one-liner smoke test is what I would add to CI first:</p>
<pre><code class="" data-line="">$ printf &#039;\n\n\n&#039; | sarvam --init; echo &quot;exit=$?&quot;
  init aborted — input ended before every question was answered.
  Nothing written to /home/vamshi/.sarvam/config.json.
exit=1          # ← was 0 before the fix. Non-zero is the whole point.
</code></pre>
<p>For the interactive path, drive a pseudo-terminal. Additionally, this needs no new dependencies — it is Python&#8217;s stdlib:</p>
<pre><code class="" data-line="">#!/usr/bin/env python3
&quot;&quot;&quot;Send Ctrl+D at a prompt and assert the CLI exits like it means it.&quot;&quot;&quot;
import os, pty, subprocess, time

master, slave = pty.openpty()
p = subprocess.Popen([&quot;sarvam&quot;], stdin=slave, stdout=slave, stderr=slave, close_fds=True)
os.close(slave)

time.sleep(1.0)            # let it draw its prompt
os.write(master, b&quot;\x04&quot;)  # Ctrl+D  (use b&quot;\x03&quot; for Ctrl+C)
time.sleep(1.0)

print(&quot;exit:&quot;, p.wait(timeout=5))
</code></pre>
<p>Running it against the fixed build:</p>
<pre><code class="" data-line="">$ python3 test_eof.py
exit: 0         # ← clean shutdown, farewell line printed
                #   before the fix this also printed 0 — but with no farewell
</code></pre>
<p>That distinction is the whole test. Together, the exit code and the farewell line tell you which of the two happened. This is what <a href="https://linuxcent.com/continuous-security-validation/">continuous security validation</a> looks like at CLI scale: assert the observable behaviour, not just the status.</p>
<h2 id="quick-reference">Quick Reference</h2>
<p>Expected behaviour after the fix:</p>
<table>
<thead>
<tr>
<th>Input</th>
<th>Exit code</th>
<th>Behaviour</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">exit</code> / <code class="" data-line="">quit</code></td>
<td>0</td>
<td>Normal shutdown</td>
</tr>
<tr>
<td><strong>Ctrl+D</strong> (EOF)</td>
<td>0</td>
<td>Clean shutdown, farewell printed</td>
</tr>
<tr>
<td><strong>Ctrl+C</strong> (SIGINT)</td>
<td><strong>130</strong></td>
<td>Clean shutdown, distinguishable from success</td>
</tr>
<tr>
<td>Piped stdin, too few lines</td>
<td><strong>1</strong></td>
<td>Aborts, writes nothing</td>
</tr>
<tr>
<td>Approval prompt, stdin closed</td>
<td>—</td>
<td>Returns <code class="" data-line="">false</code> — action declined</td>
</tr>
</tbody>
</table>
<p>Use 130 for SIGINT. That is the <code class="" data-line="">128 + signal</code> convention, and the only way a wrapping script can tell &#8220;the user interrupted this&#8221; from &#8220;this finished.&#8221; However, it needs an explicit handler, because readline&#8217;s default is a silent close:</p>
<pre><code class="" data-line="">let interrupted = false;
rl.on(&quot;SIGINT&quot;, () =&gt; {
  interrupted = true;
  rl.close();
});
// …after the loop:
if (interrupted) process.exitCode = 130;
</code></pre>
<h2 id="beyond-node-any-callback-with-a-path-that-never-runs">Beyond Node: Any Callback With a Path That Never Runs</h2>
<p>The specific API is Node&#8217;s. However, the pattern is everywhere: <strong>an async primitive whose completion callback has a path that never runs.</strong></p>
<p>Any time you wrap a callback API in a promise, ask the same question — <em>what are all the ways this callback might not be called?</em> Closed streams, cancelled requests, timed-out sockets, aborted signals. In every one of those cases, a bare <code class="" data-line="">new Promise(resolve =&gt; api(cb))</code> becomes a permanent hang. Moreover, in an event-loop runtime, a permanent hang looks exactly like a clean exit.</p>
<p>The tell is a process that ends <strong>without printing whatever it normally prints on the way out</strong>. If your CLI has a farewell line, a summary, or a flush, its absence is your signal — not the exit code, which is lying.</p>
<h2 id="cissp-domain-mapping">CISSP Domain Mapping</h2>
<table>
<thead>
<tr>
<th>Domain</th>
<th>Name</th>
<th>Relevance</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>3</strong></td>
<td>Security Architecture and Engineering</td>
<td>Secure defaults and fail-closed design. When the system loses the ability to obtain consent, it must assume consent was refused. Note how narrowly it was avoided: <code class="" data-line="">=== &quot;y&quot;</code> fails closed, <code class="" data-line="">!== &quot;n&quot;</code> fails open, and code review rarely catches the difference.</td>
</tr>
<tr>
<td><strong>8</strong></td>
<td>Software Development Security</td>
<td>Error handling at trust boundaries. Silent failure is the anti-pattern — a system that cannot distinguish &#8220;succeeded&#8221; from &#8220;never ran&#8221; cannot be reasoned about, and every consumer downstream inherits the ambiguity.</td>
</tr>
<tr>
<td><strong>7</strong></td>
<td>Security Operations</td>
<td>Exit codes are an operational interface. <code class="" data-line="">0</code> means a shell <code class="" data-line="">&amp;&amp;</code> chain proceeds. Returning 0 from a function that did nothing is, in automation terms, a false negative on an integrity check.</td>
</tr>
</tbody>
</table>
<h2 id="key-takeaways">Key Takeaways</h2>
<ol>
<li><strong><code class="" data-line="">rl.question()</code> never fires if the interface closes first.</strong> Ctrl+D, Ctrl+C, and exhausted piped stdin all close it.</li>
<li><strong>A pending promise is not a running task.</strong> Node exits cleanly when the loop empties, so the failure presents as success.</li>
<li><strong>Resolve to a sentinel, don&#8217;t reject.</strong> End of input is normal, not exceptional.</li>
<li><strong>Decide what &#8220;input ended&#8221; means per call site.</strong> Exit at a prompt, <em>decline</em> at a consent gate, abort at a wizard.</li>
<li><strong>Never assume consent from absent input.</strong> Write <code class="" data-line="">=== &quot;y&quot;</code>, never <code class="" data-line="">!== &quot;n&quot;</code>.</li>
<li><strong>A partial write can be worse than no write</strong> — especially where an empty string is a meaningful, shadowing value.</li>
<li><strong>Test the interactive path in a pty.</strong> A pipe tests the other code path entirely.</li>
<li><strong>Reproduce before you fix.</strong> My confident first diagnosis was wrong, and only a reproduction attempt caught it before it became a wasted refactor.</li>
</ol>
<h2 id="try-sarvam-cli">Try sarvam-cli</h2>
<p>The CLI in this post is <a href="https://github.com/indic-ai-contribs/sarvam-cli">sarvam-cli</a> — an MIT-licensed, open-source agentic coding assistant powered by Sarvam AI. It reads, writes, and edits files and runs shell commands in your project, with your approval before any side effect. That approval gate is exactly the one discussed above, which is why the fail-closed behaviour mattered enough to write up.</p>
<pre><code class="" data-line="">git clone https://github.com/indic-ai-contribs/sarvam-cli.git
cd sarvam-cli
npm install
npm run build
npm link

sarvam --init     # exits non-zero now if you don&#039;t finish the wizard
</code></pre>
<p>The fixes described here shipped in v0.2.9 and v0.2.10. Issues and pull requests are welcome — particularly from anyone who has fought the same class of bug in their own CLI. If the project is useful to you, a star on <a href="https://github.com/indic-ai-contribs/sarvam-cli">the sarvam-cli GitHub repo</a> genuinely helps it reach more Indian-language AI developers.</p>
<p>Get the next deep-dive in your inbox when it publishes → <a href="https://linuxcent.com/#subscribe">subscribe to linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&amp;linkname=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fnodejs-readline-silent-exit%2F&#038;title=Exit%20Code%200%20Lied%3A%20The%20Silent%20Node.js%20Bug%20That%20Hit%20sarvam-cli%20Three%20Times" data-a2a-url="https://linuxcent.com/nodejs-readline-silent-exit/" data-a2a-title="Exit Code 0 Lied: The Silent Node.js Bug That Hit sarvam-cli Three Times"></a></p><p>The post <a href="https://linuxcent.com/nodejs-readline-silent-exit/">Exit Code 0 Lied: The Silent Node.js Bug That Hit sarvam-cli Three Times</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/nodejs-readline-silent-exit/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2295</post-id>	</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 

Served from: linuxcent.com @ 2026-08-21 11:06:54 by W3 Total Cache
-->