Skip to content
Open
Show file tree
Hide file tree
Changes from 15 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
1 change: 1 addition & 0 deletions src/js/builtins/BunBuiltinNames.h
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ using namespace JSC;
macro(requireESM) \
macro(requireMap) \
macro(requireNativeModule) \
macro(resetStdioForHotReload) \
macro(resolveSync) \
macro(resume) \
macro(sameSite) \
Expand Down
81 changes: 71 additions & 10 deletions src/js/builtins/ProcessObjectInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ export function getStdioWriteStream(
stream._isStdio = true;
stream.fd = fd;

// For `bun --hot`: drop any listeners user code attached on the previous
// load so a fresh module evaluation doesn't stack duplicate handlers
// (e.g. node:readline's 'resize' listener). Called from
// GlobalObject::reload(). There are no internal listeners to preserve on
// the write-side stdio streams. (#15027)
Comment thread
robobun marked this conversation as resolved.
Outdated
stream.$resetStdioForHotReload = function () {
stream.removeAllListeners();
};

const underlyingSink = stream[require("internal/fs/streams").kWriteStreamFastPath];
$assert(underlyingSink);
return [stream, underlyingSink];
Expand Down Expand Up @@ -283,46 +292,98 @@ export function getStdinStream(
}
stream._read = triggerRead;

stream.on("resume", () => {
function onStreamResume() {
if (stream.isPaused()) return; // fake resume
$debug('on("resume");');
own();
stream._undestroy();
stream_destroyed = false;
});

stream._readableState.reading = false;
}

stream.on("pause", () => {
function onStreamPause() {
process.nextTick(() => {
// Only disown if the stream is still paused (not resumed in the meantime)
if (!stream.readableFlowing) {
stream._readableState.reading = false;
disown();
}
});
});
}

// The stream is created with autoClose: false so autoDestroy is off; match
// Node by destroying stdin once 'end' has emitted ('close' follows 'end').
stream.on("end", () => {
function onStreamEnd() {
if (!stream_destroyed) {
stream_destroyed = true;
process.nextTick(() => {
stream.destroy();
});
}
});
}

stream.on("close", () => {
function onStreamClose() {
if (!stream_destroyed) {
stream_destroyed = true;
process.nextTick(() => {
stream.destroy();
disown();
});
}
});
}

stream.on("resume", onStreamResume);
stream.on("pause", onStreamPause);
stream.on("end", onStreamEnd);
stream.on("close", onStreamClose);

stream._readableState.reading = false;

// For `bun --hot`: the process object (and this stream) survive module
// registry reloads. User code (e.g. node:readline) that attached 'data',
// 'keypress', etc. listeners on the previous load must be detached so the
// fresh module evaluation doesn't stack a second set of handlers on the
// same native fd. Called from GlobalObject::reload(). (#15027)
Comment thread
robobun marked this conversation as resolved.
Outdated
stream.$resetStdioForHotReload = function () {
disown();
Comment thread
robobun marked this conversation as resolved.
// removeAllListeners() alone leaves the Readable's kDataListening bit
// set and schedules updateReadableListening() on the next tick, which
// would call self.resume() → own() and undo the disown() above. Use
// removeListener('data', fn) per listener so kDataListening is cleared,
// and pause() so kFlowing is cleared — otherwise any chunk pushed by an
// in-flight internalRead before the new load attaches its handler takes
// the addChunk fast path and is emitted to zero listeners instead of
// buffered. unpipe() also clears state.pipes so destinations from the
// previous load aren't pinned for the process lifetime.
// setRawMode(false) restores cooked mode: raw mode clears ISIG, and
// once the keypress handler that translated ^C is gone Ctrl+C would be
// dead if the new load doesn't re-enter raw mode itself.
Comment thread
robobun marked this conversation as resolved.
Outdated
if (stream.isRaw) stream.setRawMode?.(false);
// node:readline is cached in the InternalModuleRegistry and is NOT
// re-evaluated on reload, so its module-local KEYPRESS_DECODER symbol
// keeps pointing at the marker left on this stream.
// emitKeypressEvents() early-returns when it sees it, so it would never
// reinstall the 'data' → 'keypress' bridge we remove below and
// terminal-mode readline would go silent after a reload. Delete it so
// the next createInterface() wires keypress events up again. The
// escape-decoder generator is left alone: emitKeypressEvents()
// overwrites it unconditionally once the guard passes, and a pending
// escape-sequence timer from the previous load dereferences it, so
// deleting it would turn that timer into an uncaught TypeError.
Comment thread
robobun marked this conversation as resolved.
Outdated
for (const sym of Object.getOwnPropertySymbols(stream)) {
if (sym.description === "keypress-decoder") {
delete stream[sym];
}
}
Comment thread
robobun marked this conversation as resolved.
stream.unpipe();
for (const fn of stream.listeners("data")) stream.removeListener("data", fn);
originalPause.$call(stream);
stream.removeAllListeners();
stream.on("resume", onStreamResume);
Comment thread
robobun marked this conversation as resolved.
stream.on("pause", onStreamPause);
stream.on("end", onStreamEnd);
stream.on("close", onStreamClose);
Comment thread
robobun marked this conversation as resolved.
stream._readableState.reading = false;
};
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.

return stream;
}
Expand Down
41 changes: 41 additions & 0 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2988,6 +2988,47 @@ static JSValue constructStdin(VM& vm, JSObject* processObject)
return result;
}

// In `--hot` mode the process object and its stdio streams survive module
// registry reloads. User code (e.g. node:readline) that attached listeners on
// the previous load must be detached so the fresh evaluation doesn't stack a
// second set of handlers on the same stream. Called from
// GlobalObject::reload(). (#15027)
Comment thread
robobun marked this conversation as resolved.
Outdated
void resetStdioForHotReload(Zig::GlobalObject* globalObject)
{
if (!globalObject->hasProcessObject()) {
return;
}

auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
Comment thread
robobun marked this conversation as resolved.
auto* process = globalObject->processObject();
const auto& resetName = WebCore::builtinNames(vm).resetStdioForHotReloadPrivateName();

for (auto name : { "stdin"_s, "stdout"_s, "stderr"_s }) {
// getDirect() only returns the value if the PropertyCallback was
// already reified by a prior access; it does not trigger lazy
// construction of the stream.
Comment thread
robobun marked this conversation as resolved.
Outdated
JSValue stream = process->getDirect(vm, Identifier::fromString(vm, name));
if (!stream || !stream.isObject()) {
continue;
}

JSValue resetFn = stream.getObject()->getDirect(vm, resetName);
if (!resetFn) {
continue;
}

auto callData = JSC::getCallData(resetFn);
if (callData.type == JSC::CallData::Type::None) {
continue;
}

JSC::MarkedArgumentBuffer args;
JSC::profiledCall(globalObject, ProfilingReason::API, resetFn, callData, stream, args);
CLEAR_IF_EXCEPTION(scope);
}
}

static JSValue constructProcessSend(VM& vm, JSObject* processObject)
{
auto* globalObject = processObject->globalObject();
Expand Down
2 changes: 2 additions & 0 deletions src/jsc/bindings/BunProcess.h
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,6 @@ JSC_DECLARE_HOST_FUNCTION(Process_functionDlopen);
// callback shims in src/js.
JSC_DECLARE_HOST_FUNCTION(jsFunctionReportUncaughtException);

void resetStdioForHotReload(Zig::GlobalObject* globalObject);

} // namespace Bun
10 changes: 10 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3582,6 +3582,16 @@ void GlobalObject::reload()
{
auto& vm = this->vm();
auto scope = DECLARE_THROW_SCOPE(vm);

// process.stdin/stdout/stderr survive reloads. Drop user-attached
// listeners from the previous load so a fresh readline/etc. doesn't
// stack duplicate handlers on the same stream. (#15027)
Comment thread
robobun marked this conversation as resolved.
Outdated
Bun::resetStdioForHotReload(this);
// resetStdioForHotReload clears any exception it raises, but its
// ThrowScope's destructor still simulates a throw; satisfy the check
// before requireMap()->clear() constructs the next scope.
Comment thread
robobun marked this conversation as resolved.
Outdated
scope.assertNoExceptionExceptTermination();

{
auto* moduleLoader = this->moduleLoader();
WTF::Locker locker { moduleLoader->cellLock() };
Expand Down
101 changes: 101 additions & 0 deletions test/cli/hot/hot-stdin-pty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
# Drives `bun --hot <fixture>` inside a pseudo-terminal so readline runs in
# terminal mode (emitKeypressEvents + raw mode), types a line, rewrites the
# fixture to trigger a hot reload, then types another line. The test asserts
# on the child's stdout, which this script forwards verbatim.
import os
import pty
import re
import select
import signal
import sys
import time

command = sys.argv[1:]
fixture = sys.argv[-1]

master_fd, slave_fd = pty.openpty()
pid = os.fork()
if pid == 0:
os.close(master_fd)
os.setsid()
os.dup2(slave_fd, 0)
os.dup2(slave_fd, 1)
os.dup2(slave_fd, 2)
if slave_fd > 2:
os.close(slave_fd)
os.execvp(command[0], command)
Comment thread
claude[bot] marked this conversation as resolved.

os.close(slave_fd)
buffer = b""


def wait_for(pattern, timeout=10):
global buffer
rx = re.compile(pattern.encode())
deadline = time.time() + timeout
while time.time() < deadline:
if rx.search(buffer):
return True
ready = select.select([master_fd], [], [], 0.1)[0]
if ready:
try:
data = os.read(master_fd, 4096)
except OSError:
# Linux raises EIO once the child side of the PTY is closed.
break
if not data:
# macOS/BSD report EOF as an empty read instead; bail out so
# we don't spin on a closed fd until the deadline.
break
buffer += data
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
return rx.search(buffer) is not None


def terminate_handler(signum, frame):
# The hand-rolled fork above does not give the child a controlling TTY,
# so closing the PTY master won't SIGHUP it. If we're torn down early
# (test-runner timeout sends SIGTERM, Ctrl+C sends SIGINT, or our own
# SIGALRM fires), kill the `bun --hot` child explicitly so it doesn't
# outlive the test.
print("PYTHON: terminated by signal %d" % signum, flush=True)
try:
os.kill(pid, 9)
except Exception:
pass
sys.exit(1)


signal.signal(signal.SIGALRM, terminate_handler)
signal.signal(signal.SIGTERM, terminate_handler)
signal.signal(signal.SIGINT, terminate_handler)
signal.alarm(30)

ok = wait_for(r"READY 1 ")
os.write(master_fd, b"hello\r")
ok = wait_for(r"ECHO 1 hello") and ok

# Trigger a hot reload by rewriting the fixture in place.
with open(fixture) as f:
source = f.read()
with open(fixture, "w") as f:
f.write(source)

# READY 2 is printed after createInterface() has synchronously re-wired the
# data→keypress bridge, so it is safe to type immediately once it appears.
ok = wait_for(r"READY 2 ") and ok
os.write(master_fd, b"world\r")
# Wait for any ECHO of "world" (from whichever load) or time out.
wait_for(r"ECHO \d+ world", timeout=5)
# Drain a little longer so a duplicate echo (the pre-fix bug) is captured too.
wait_for(r"\x00nevermatches\x00", timeout=1)

print("PYTHON: done", flush=True)
try:
os.kill(pid, 9)
except Exception:
pass
os.waitpid(pid, 0)
sys.exit(0 if ok else 1)
Loading