Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5e138a4
feat(accessors): add createAccessor factory, move IndirectNThreadHost…
woldann Jul 15, 2026
1a5e165
style: run prettier on accessors.ts and two test files
claude Jul 15, 2026
44040c2
feat(accessors): add createAccessorWithoutInit, make createAccessor a…
claude Jul 15, 2026
63be31f
feat(accessors): add createAccessor idType 'processAllThreadIds'
claude Jul 15, 2026
7ccd910
refactor(accessors): race raw NThread hijacks, not full IndirectNThre…
claude Jul 15, 2026
cca090e
feat(accessors): default idType to processAllThreadIds, aggressivenes…
claude Jul 15, 2026
804fa30
test(accessors): drop unnecessary IndirectNThreadHostAccessor casts
claude Jul 15, 2026
73a41b4
fix(accessors): actually cancel losing NThread candidates in raceProc…
claude Jul 15, 2026
f6371eb
feat(accessors): overload createAccessor/createAccessorWithoutInit to…
claude Jul 15, 2026
001e57e
feat(nshm,accessors): make NShm a real HostAccessor so createAccessor…
claude Jul 15, 2026
c3d43e0
refactor(accessors): rework nthread racing around a generic register-…
woldann Jul 16, 2026
28b8829
test(accessors): prove sync struct access works with level-2 accessors
claude Jul 16, 2026
a5b77c7
fix(accessors): race nthread candidates safely via asynclocalstorage
woldann Jul 17, 2026
e4fadee
feat(workspace): use the accessor factory in the notepad keystroke ho…
woldann Jul 17, 2026
5d77e5b
feat(dummy): add a visible option for spawning a real, clickable window
woldann Jul 17, 2026
0652d2c
fix(nthread): await stub-scan readiness and drop the auto-suspend foo…
woldann Jul 18, 2026
dc280ef
fix(nhook): exclude memory's own driving thread from enable()/disable()
woldann Jul 18, 2026
705b776
fix(accessors): stop overriding the poll interval with a slower value
woldann Jul 18, 2026
8083b45
fix(workspace): stop keeping notepad's hijacked thread parked for the…
woldann Jul 18, 2026
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
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,16 @@ Net effect: edit `packages/xffi/src/*.ts`, then run a test that exercises a _cro

**Fix: `bun run build`** regenerates every package's `dist/` from current `src/`, which resolves the split immediately (confirmed: 147/147 clean afterward). **After editing any `packages/*/src/` file that another package imports across the workspace boundary (i.e. anything except `tests/`-only or single-package changes), run `bun run build` before trusting a Wine test result that touches the consuming package** — a clean typecheck/lint is not sufficient evidence the runtime picked up the change, precisely because typecheck/lint and `tests/` both only ever see live `src/` through tsconfig paths, while cross-package `src/`-to-`src/` imports don't.

#### `instanceof` against a class from another package is unreliable under Wine, even with a fully current `dist/`

Distinct from the stale-`dist/` issue above: `bun install` here uses Bun's isolated linker, so **every** consuming package gets its **own** `node_modules/<dep>` symlink (e.g. `packages/exoproc/node_modules/bun-xffi`, `packages/nshm/node_modules/bun-xffi`, `packages/accessors/node_modules/bun-xffi` — five-plus independent symlinks, all `-> ../../xffi`, i.e. all pointing at the literal same directory). On native Linux `bun` this is transparent. Under `bun.exe` via Wine it is not: each distinct symlink path a module is reached through gets evaluated as its own separate module instance, even though every path resolves to the identical `dist/index.js`.

Concretely hit this during a since-reverted experiment where `NShm` (in `bun-nshm`) briefly extended `HostAccessor` (from `bun-xffi`) instead of the current `MiddlewareAccessor`: a test importing `HostAccessor` from `'exoproc'` and asserting `expect(nshmInstance).toBeInstanceOf(HostAccessor)` failed even though `nshmInstance` genuinely was built via `class NShm extends HostAccessor`. Root-caused with a throwaway test importing `HostAccessor` three ways (`from 'exoproc'`, `from 'bun-xffi'` directly, `from 'exoproc-accessors'`) plus inspecting the live instance's actual prototype chain: all four `HostAccessor` references were pairwise `!==`, despite identical bundled code -- the received object's constructor printed as `HostAccessor` by name, just not the same `HostAccessor` the test file's own import produced. **There is no import path from a test file that reliably lands on the same module instance a deeply-nested package used to build its class** -- don't chase it by changing _which_ package you import the class from; assert by name (`instance.constructor.name`) instead of `toBeInstanceOf(...)` if you're ever in this situation again. `instanceof` checks against a class declared in the _same_ package as the code under test (e.g. `IndirectNThreadHostAccessor`, declared and constructed both within `exoproc-accessors`) are unaffected and remain reliable -- the risk is specific to classes that live in a different package than the code constructing the instance.

#### A test run that prints nothing and never returns usually means a previous run's process leaked

`./bun-wine test ...` (or `bun-wine test ...`) can hang indefinitely with **zero** stdout — not even the `bun test vX.Y.Z` banner line that normally appears within a second or two. Root cause each time this was investigated: an earlier run's `bun.exe` process died (crashed, was killed, or was interrupted) without its spawned dummy `ping.exe` child exiting; that orphaned child inherited a handle to `bun.exe`'s stdout pipe, so a shell pipeline reading from the new run (`| tail`, redirected to a file, doesn't matter) blocks forever waiting for an EOF that can only come once every handle to the pipe closes — the current run's own `bun.exe` may have already finished or never even gotten that far. Symptom to check for: `ps -eo pid,ppid,stat,etime,pcpu,cmd | grep -iE "wine|ping.exe"` shows a `ping.exe` from a **prior** invocation (elapsed time far longer than the current run) still alive, sometimes pegged at ~100% CPU if the leaked process's thread was left mid-hijack in a `jmp $` (`EB FE`) spin. **Fix: `kill -9` every stray `wineserver`/`winedevice.exe`/`ping.exe` from prior runs before retrying** (a healthy run's own dummy process is fine to leave running _during_ that run — only kill leftovers from runs that have already ended one way or another). A `ping.exe` idling at 0% CPU is harmless clutter; one pegged at high CPU is actively starving the Wine scheduler and will cause unrelated, otherwise-healthy runs to time out (e.g. spurious `CallTimeoutError: Thread did not return to sleep address` on a completely different test) until it's killed.

### Adding a new workspace package

When creating a package under `packages/<name>`:
Expand Down
6 changes: 4 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 53 additions & 23 deletions examples/notepad-keystroke-hook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@
*
* What actually touches notepad.exe's memory: `nhook.create()`/`enable()`/
* `disable()` only need `ReadProcessMemory`/`WriteProcessMemory`/
* `VirtualProtectEx` (via a plain `RemoteCallableMemoryAccessor`) to install
* the 2-byte `EB FE` patch -- no thread hijacking needed for that part. Only
* when a real thread in notepad.exe actually *runs into* the patch does
* `nhook` hijack that specific parked thread (via `IndirectNThreadHostAccessor`,
* internally) to read its arguments and safely resume it afterwards. No
* `CreateRemoteThread`, no injected DLL, no injected machineCode loop,
* anywhere in this script.
* `VirtualProtectEx` to install the 2-byte `EB FE` patch, driven here through
* `createAccessor(notepad.pid, createAccessorOptions(2))` -- races every
* thread of notepad.pid for an `NThread` hijack and hands back an
* `IndirectNThreadHostAccessor`, so those ops run via thread redirection
* rather than a fresh `CreateRemoteThread`. Separately, when a real thread in
* notepad.exe actually *runs into* the patch, `nhook` hijacks that specific
* parked thread (its own, unrelated `IndirectNThreadHostAccessor`) to read
* its arguments and safely resume it afterwards. No `CreateRemoteThread`, no
* injected DLL, no injected machineCode loop, anywhere in this script.
*
* By default this is purely observational: the detour is never a custom
* function that replaces `TranslateMessage` (that's what `minhook`'s
Expand Down Expand Up @@ -70,11 +72,12 @@
*/
import {
User32Impl,
RemoteCallableMemoryAccessor,
Msg,
WM,
NHook,
ProcessExitedError,
createAccessor,
createAccessorOptions,
} from 'exoproc';
import { DummyProcess } from 'exoproc-dummy';
import { createDemo } from '../kit/server.js';
Expand Down Expand Up @@ -140,26 +143,50 @@ const demo = createDemo({
},
});

const notepad = new DummyProcess({ executable: 'notepad.exe', args: [] });
// visible: true -- this demo needs a real window you can click into and
// type at; DummyProcess otherwise spawns headless (CREATE_NO_WINDOW, no
// explicit lpDesktop), which is right for tests/most examples but leaves
// nothing to see here.
const notepad = new DummyProcess({
executable: 'notepad.exe',
args: [],
visible: true,
});
demo.publishProcess(notepad.pid, true);
demo.publishStatus(
`Spawned notepad.exe (pid=${notepad.pid}). Installing hook...`,
);

const target = User32Impl.TranslateMessage;
const nhook = new NHook(notepad.pid);
// Reusing notepad.handle -- closeHandle: false is required here, otherwise
// memory.close() and notepad.stop() would both call CloseHandle on the same
// handle (see CLAUDE.md / the accessor.test.ts fix for exactly this
// double-CloseHandle bug).
const memory = new RemoteCallableMemoryAccessor(notepad.pid, {
handle: notepad.handle,
closeHandle: false,
});

// createAccessor's NThread hijack parks notepad's own thread at *our* spin
// stub for as long as the accessor is alive -- notepad's message loop (and
// so keyboard handling) never runs on its own while that's true, on a
// single-threaded target like this one. Building one fresh, short-lived
// accessor per operation (create/enable/disable) and deinit()ing it right
// after -- instead of keeping one alive for the whole session -- means
// notepad is only ever unresponsive for the brief moment an operation is
// actually running, not for the entire demo. hook.enable()/hook.disable()
// (the convenience forwarders) are deliberately not used below: they always
// reuse whatever `memory` was passed to nhook.create() (Hook.memory is
// readonly), which would keep that original accessor's hijack alive
// indefinitely -- nhook.enable()/disable() (the manager methods) take
// `memory` explicitly instead, so each call can bring its own.
async function withMemory<T>(
fn: (memory: Awaited<ReturnType<typeof createAccessor>>) => Promise<T>,
): Promise<T> {
const memory = await createAccessor(notepad.pid, createAccessorOptions(2));
try {
return await fn(memory);
} finally {
await memory.deinit();
}
}

try {
const hook = await nhook.create(memory, target);
await hook.enable();
const hook = await withMemory((memory) => nhook.create(memory, target));
await withMemory((memory) => nhook.enable(memory, hook));
demo.publishStatus('Hooked TranslateMessage -- watching for keystrokes.');

const deadline = Date.now() + HOOK_DURATION_MS;
Expand All @@ -175,12 +202,12 @@ try {
// the same hook/thread.
if (pauseRequested && hook.enabled) {
pauseRequested = false;
await hook.disable();
await withMemory((memory) => nhook.disable(memory, hook));
demo.publishStatus('Paused -- hook disabled.');
}
if (resumeRequested && !hook.enabled) {
resumeRequested = false;
await hook.enable();
await withMemory((memory) => nhook.enable(memory, hook));
demo.publishStatus('Resumed -- hook enabled, watching for keystrokes.');
}

Expand Down Expand Up @@ -259,13 +286,16 @@ try {
nhook.forget(target);
demo.publishStatus('notepad.exe closed -- hook removed.');
} else {
await hook.disable();
await withMemory((memory) => nhook.disable(memory, hook));
// hook.enabled is now false, so destroy() (which internally re-calls
// disable() only if still enabled) never touches hook.memory -- the
// long-deinit()'d accessor from the very first withMemory() above --
// so this is safe without giving it a fresh one too.
await hook.destroy();
demo.publishStatus('Done -- hook removed.');
demo.publishProcess(notepad.pid, false);
}
} finally {
memory.close();
await notepad.stop();
demo.close();
}
3 changes: 3 additions & 0 deletions packages/accessors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
},
"dependencies": {
"bun-xffi": "workspace:*",
"bun-winapi": "workspace:*",
"bun-nthread": "workspace:*",
"bun-nshm": "workspace:*",
"exoproc-utils": "workspace:*"
}
}
Loading
Loading