Skip to content
Merged
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
15 changes: 10 additions & 5 deletions native/terminal.cc
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ void ThrowResult(Napi::Env env, const char* operation, GhosttyResult result) {

} // namespace

Napi::FunctionReference TerminalWrap::constructor;

void TerminalWrap::Init(Napi::Env env, Napi::Object exports) {
Napi::Function func = DefineClass(
env,
Expand All @@ -58,13 +56,20 @@ void TerminalWrap::Init(Napi::Env env, Napi::Object exports) {
InstanceMethod("dispose", &TerminalWrap::Dispose),
});

constructor = Napi::Persistent(func);
constructor.SuppressDestruct();
// Node runs Init once for each load of the addon: once in the main thread
// and once in every worker_threads Worker that loads it. Each load has its
// own napi_env, so keep the class reference in that env's instance data,
// never in a static: a reference belongs to the env that created it. Node
// deletes the instance data when the env shuts down.
assert(env.GetInstanceData<Napi::FunctionReference>() == nullptr);
env.SetInstanceData<Napi::FunctionReference>(new Napi::FunctionReference(Napi::Persistent(func)));
exports.Set("createTerminal", Napi::Function::New(env, CreateTerminal));
}

Napi::Object TerminalWrap::Create(Napi::Env env, const Napi::Object& options) {
return constructor.New({options});
Napi::FunctionReference* constructor = env.GetInstanceData<Napi::FunctionReference>();
assert(constructor != nullptr && "TerminalWrap::Init runs before createTerminal is exported");
return constructor->New({options});
}

TerminalWrap::TerminalWrap(const Napi::CallbackInfo& info)
Expand Down
2 changes: 0 additions & 2 deletions native/terminal.hh
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ class TerminalWrap final : public Napi::ObjectWrap<TerminalWrap> {
~TerminalWrap() override;

private:
static Napi::FunctionReference constructor;

void DisposeNative();
GhosttyTerminal RequireTerminal(Napi::Env env);
Napi::Value Feed(const Napi::CallbackInfo& info);
Expand Down
117 changes: 117 additions & 0 deletions test/workers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { nativeSupport } from "./native-support.js";

const support = nativeSupport();
const describeIfNative = support.available ? describe : describe.skip;
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");

// Each scenario runs in a child process: a broken addon aborts or segfaults the
// whole process, which would otherwise kill the test runner.
const prelude = `
const { Worker } = require("node:worker_threads");
const { once } = require("node:events");
const addon = require("node-gyp-build")(process.env.ADDON_ROOT);
function use(label) {
const t = addon.createTerminal({ cols: 10, rows: 1 });
t.feed(label);
const text = t.getVisibleText();
t.dispose();
if (text !== label) throw new Error("expected " + label + ", got " + JSON.stringify(text));
}
const workerSource = \`
const { parentPort } = require("node:worker_threads");
const addon = require("node-gyp-build")(process.env.ADDON_ROOT);
function use(label) {
const t = addon.createTerminal({ cols: 10, rows: 1 });
t.feed(label);
const text = t.getVisibleText();
t.dispose();
if (text !== label) throw new Error("expected " + label + ", got " + JSON.stringify(text));
}
use("w-load");
parentPort.postMessage("loaded");
parentPort.on("message", (msg) => {
if (msg === "exit") { parentPort.close(); return; }
use(msg);
parentPort.postMessage("used " + msg);
});
\`;
async function startWorker() {
const w = new Worker(workerSource, { eval: true });
const [msg] = await once(w, "message");
if (msg !== "loaded") throw new Error("worker did not load: " + msg);
return w;
}
async function workerUses(w, label) {
w.postMessage(label);
const [msg] = await once(w, "message");
if (msg !== "used " + label) throw new Error("worker failed: " + msg);
}
async function stopWorker(w) {
w.postMessage("exit");
await once(w, "exit");
}
`;

// The child gets a bounded run, and the test waits longer than that, so a
// hung child fails with its own output instead of a Vitest timeout.
const CHILD_TIMEOUT_MS = 20_000;
const TEST_TIMEOUT_MS = 30_000;

function runScenario(body: string) {
const script = `${prelude}\n(async () => { ${body}\n console.log("done"); })().catch((err) => { console.error(err); process.exit(1); });`;
return spawnSync(process.execPath, ["-e", script], {
cwd: root,
encoding: "utf8",
env: { ...process.env, ADDON_ROOT: root },
timeout: CHILD_TIMEOUT_MS,
});
}

function expectSurvived(result: ReturnType<typeof runScenario>) {
// stderr is not asserted (NODE_OPTIONS such as --inspect can print to it),
// but it is the failure message: it holds the V8 fatal error or the stack.
expect(
{ signal: result.signal, status: result.status, stdout: result.stdout.trim() },
result.stderr,
).toEqual({ signal: null, status: 0, stdout: "done" });
}

describeIfNative("worker_threads", () => {
it("creates terminals in the main thread after a worker loads the addon", () => {
expectSurvived(
runScenario(`
use("main-1");
const w = await startWorker();
use("main-2");
await workerUses(w, "w-2");
await stopWorker(w);`),
);
}, TEST_TIMEOUT_MS);

it("creates terminals in the main thread after a worker that loaded the addon exits", () => {
expectSurvived(
runScenario(`
use("main-1");
const w = await startWorker();
await stopWorker(w);
use("main-2");`),
);
}, TEST_TIMEOUT_MS);

it("creates terminals in each of two workers", () => {
expectSurvived(
runScenario(`
const w1 = await startWorker();
const w2 = await startWorker();
await workerUses(w1, "w1-2");
await workerUses(w2, "w2-2");
await stopWorker(w1);
await workerUses(w2, "w2-3");
await stopWorker(w2);`),
);
}, TEST_TIMEOUT_MS);
});
Loading