Debugging "Native host has exited"
Diagnose the 'Native host has exited' native messaging error in Chrome and Firefox: run the host by hand, read its stderr, check permissions, runtimes, stdout pollution and early exits.
Table of Contents
The port disconnects the instant you open it and chrome.runtime.lastError.message reads “Native host has exited.” Nothing else. It is the most common native messaging error and the least informative, because it only says that the browser found your host, launched it, and then saw its stdout close. The cause could be a crash, a missing runtime, a file the host could not open, an executable bit that is not set, a single print statement, or a host that simply returned from main too early. This guide narrows it down methodically. It sits under native messaging and host integration.
What the error actually means
The browser’s native messaging client has three stages: find and validate the host manifest, launch the process, and exchange frames. “Not found” and “forbidden” are stage-one errors. “Native host has exited” is stage two or three: the process started (or the OS was asked to start it), and the read end of its stdout reached EOF before the browser expected. The browser does not distinguish between “the OS refused to run the file”, “the program crashed on line one”, and “the program ran perfectly and then exited” — all three close stdout. That is why the fix is always the same first step: take the browser out of the loop and run the host yourself, with the same arguments and the same environment the browser would use.
Step-by-step diagnosis
1. Capture the exact error and when it happens
1// sw.js — temporary diagnostic
2const port = chrome.runtime.connectNative("com.acme.vault");
3const t0 = performance.now();
4port.onDisconnect.addListener(() => {
5 console.error("disconnect after", Math.round(performance.now() - t0), "ms:",
6 chrome.runtime.lastError?.message);
7});
8port.onMessage.addListener((m) => console.log("host said", m));
9port.postMessage({ type: "hello" });
Execution context: the service worker console. The time is a useful clue. A disconnect within a few milliseconds usually means the OS could not execute the file at all; tens to hundreds of milliseconds suggests the runtime started and then failed; a disconnect after a valid reply means the host exits once it has answered — fine for sendNativeMessage, wrong for a port. In Firefox read port.error instead of lastError.
2. Run the host exactly as the browser does
Read path from the host manifest and run that, with the arguments the browser passes and a framed message on stdin.
1HOST="/opt/acme-vault/vault-host"
2ORIGIN="chrome-extension://abcdefghijklmnopabcdefghijklmnop/"
3printf '\x11\x00\x00\x00{"type":"hello"}' | env -i HOME="$HOME" PATH="/usr/bin:/bin" "$HOST" "$ORIGIN"
4echo "exit code: $?"
Execution context: a terminal. env -i with a minimal PATH mimics the stripped environment browsers launch hosts with — the single most common difference between “works in my terminal” and “exits under Chrome”. The printf sends a correctly framed 17-byte hello. Watch what appears: an error message, a stack trace, or nothing at all followed by a non-zero exit code. On Windows, run the .exe from a plain cmd.exe and pipe a file containing the framed bytes.
3. Check that the OS will run the file
1ls -l "$HOST" # needs x permission for the user
2file "$HOST" # right architecture? arm64 vs x86_64
3xattr -l "$HOST" # macOS: com.apple.quarantine present?
4codesign --verify --verbose "$HOST" # macOS: signature valid?
5head -1 "$HOST" # script? check the shebang
Execution context: a terminal on the user’s machine (or a support script that collects these). A missing execute bit, an x86-only binary on Apple Silicon without Rosetta, a quarantine attribute from a download, or an unsigned binary blocked by Gatekeeper all make the launch fail before any of your code runs. A script whose shebang reads #!/usr/bin/env node fails when the browser’s PATH does not contain Node — which on macOS, where GUI apps do not inherit shell profiles, is the normal case.
4. Read the host’s stderr from the browser
When the host works by hand but not under the browser, you need its stderr from the real launch.
1# Chrome on macOS: stderr from native hosts goes to Chrome's log
2/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
3 --enable-logging=stderr --v=1 2>&1 | grep -i -A3 "native"
Execution context: a terminal launching a fresh Chrome instance (quit Chrome first, or use a separate --user-data-dir). Host stderr lines appear interleaved with Chrome’s own log. In Firefox, host stderr appears in the Browser Console (Ctrl+Shift+J) without any flags. Alternatively, have the host write its own log file to a known location such as ~/Library/Logs/acme-vault-host.log — the most reliable option for collecting diagnostics from users.
5. Rule out stdout pollution
1printf '\x11\x00\x00\x00{"type":"hello"}' | "$HOST" "$ORIGIN" | xxd | head -3
Execution context: a terminal. The first four bytes of output must be a little-endian length and byte five must be {. If you see readable text first — a banner, a deprecation warning, “Listening…” — something wrote to stdout. The browser read those characters as a length of hundreds of millions, gave up, and closed the pipe; your host then died of EPIPE on its next write, which is why the error says “exited” rather than “communicating”. The full framing rules are in the native messaging wire format.
6. Check that the host stays alive for a port
1// Node host: keep reading until EOF — never exit after the first reply
2process.stdin.on("end", () => process.exit(0));
3// WRONG: handle(msg); process.exit(0);
Execution context: the host process. Hosts written for sendNativeMessage often exit after replying, which is correct for one-shot use and fatal for connectNative: the port receives one reply and then disconnects with “has exited”. Exit only on stdin EOF.
Cross-browser variation
- Chrome / Edge: the message is “Native host has exited.” Chrome kills the process when the port is disconnected, so a hang on the host side looks like a timeout in your extension rather than this error.
- Firefox:
port.error.messagereads “An unexpected error occurred” or similar for several of these cases; check the Browser Console, which shows both the host’s stderr and Firefox’s own explanation of the launch failure. - Safari: no separate process — failures appear as errors from
sendNativeMessagewhen the containing app’s handler throws or is missing. Check Console.app filtered by your app’s bundle id.
Verification
After the fix, run the diagnostic from step 1 again and expect a reply and no disconnect:
1host said { protocol: 3, version: "2.0.0", features: ["vault","watch"] }
Execution context: the service worker console. Leave the port open for a minute and confirm no disconnect appears; then call port.disconnect() and confirm the host process exits (check with ps aux | grep vault-host). Repeat on a clean user account or virtual machine — the original bug was very likely an environment difference, and your own machine is the one place it will not reproduce.
FAQ
The host works with sendNativeMessage but not connectNative. Why?
The host exits after replying. For one-shot use that is correct; for a port, the host must keep reading stdin until EOF.
It works in development but not for users. What usually differs?
The environment. Users’ machines lack your shell’s PATH, your installed runtimes, your Developer ID trust and your file permissions. Ship a single self-contained executable, sign it, and test on a fresh account.
Can the extension restart the host automatically?
Yes — reconnect in onDisconnect with exponential backoff and a cap, so a host that crashes on startup does not spin in a tight loop launching processes.
Related
- The native messaging wire format — framing bugs that masquerade as exits.
- Writing a native messaging host in Node — a host that logs to stderr and exits only on EOF.
- Logging across contexts without losing messages — collecting the extension half of the story.
- Native messaging and host integration — the parent topic.