Skip to content
Open
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
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
55 changes: 45 additions & 10 deletions src/js/builtins/ProcessObjectInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ export function getStdioWriteStream(
stream._isStdio = true;
stream.fd = fd;

// Called from GlobalObject::reload(); the stream outlives each `--hot` load.
stream.$resetStdioForHotReload = function () {
stream.removeAllListeners();
};

const underlyingSink = stream[require("internal/fs/streams").kWriteStreamFastPath];
$assert(underlyingSink);
return [stream, underlyingSink];
Expand Down Expand Up @@ -283,46 +288,76 @@ 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;

// Called from GlobalObject::reload(); the stream outlives each `--hot` load.
stream.$resetStdioForHotReload = function () {
disown();
Comment thread
robobun marked this conversation as resolved.
if (stream.isRaw) stream.setRawMode?.(false);
// node:readline is not re-evaluated on reload, and emitKeypressEvents() is a
// no-op while the marker from the previous load is still on the stream. Its
// escape-decoder stays: a pending escape timeout from the old load still uses it.
Comment thread
robobun marked this conversation as resolved.
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();
// Readable clears kDataListening only in removeListener(); after a bare
// removeAllListeners() its next-tick updateReadableListening() would resume() (re-own stdin).
Comment thread
robobun marked this conversation as resolved.
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
37 changes: 37 additions & 0 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2988,6 +2988,43 @@ static JSValue constructStdin(VM& vm, JSObject* processObject)
return result;
}

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() skips streams that were never reified instead of constructing them.
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);
// A failing reset on one stream must not stop the others; a termination must.
if (!scope.tryClearException()) [[unlikely]] {
return;
}
}
}

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

// Runs each reified process.std{in,out,err}'s @resetStdioForHotReload (ProcessObjectInternals.ts).
void resetStdioForHotReload(Zig::GlobalObject* globalObject);

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

// The process object outlives the reload; drop the previous load's stdio listeners (#15027).
Bun::resetStdioForHotReload(this);
RETURN_IF_EXCEPTION(scope, );

{
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
Loading