diff --git a/CLAUDE.md b/CLAUDE.md index 2414db8..5aa1811 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` 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/`: diff --git a/bun.lock b/bun.lock index ae29013..f74fd74 100644 --- a/bun.lock +++ b/bun.lock @@ -52,6 +52,9 @@ "name": "exoproc-accessors", "version": "0.1.0", "dependencies": { + "bun-nshm": "workspace:*", + "bun-nthread": "workspace:*", + "bun-winapi": "workspace:*", "bun-xffi": "workspace:*", "exoproc-utils": "workspace:*", }, @@ -106,6 +109,7 @@ "bun-nthread": "workspace:*", "bun-winapi": "workspace:*", "bun-xffi": "workspace:*", + "exoproc-accessors": "workspace:*", "exoproc-utils": "workspace:*", }, }, @@ -114,7 +118,6 @@ "version": "0.1.0", "dependencies": { "bun-xffi": "workspace:*", - "exoproc-accessors": "workspace:*", "exoproc-dummy": "workspace:*", "exoproc-utils": "workspace:*", }, @@ -125,7 +128,6 @@ "dependencies": { "bun-winapi": "workspace:*", "bun-xffi": "workspace:*", - "exoproc-accessors": "workspace:*", "exoproc-utils": "workspace:*", }, }, diff --git a/examples/notepad-keystroke-hook/index.ts b/examples/notepad-keystroke-hook/index.ts index ff870f8..35a9643 100644 --- a/examples/notepad-keystroke-hook/index.ts +++ b/examples/notepad-keystroke-hook/index.ts @@ -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 @@ -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'; @@ -140,7 +143,15 @@ 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...`, @@ -148,18 +159,34 @@ demo.publishStatus( 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( + fn: (memory: Awaited>) => Promise, +): Promise { + 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; @@ -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.'); } @@ -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(); } diff --git a/packages/accessors/package.json b/packages/accessors/package.json index 570057c..e7ba055 100644 --- a/packages/accessors/package.json +++ b/packages/accessors/package.json @@ -45,6 +45,9 @@ }, "dependencies": { "bun-xffi": "workspace:*", + "bun-winapi": "workspace:*", + "bun-nthread": "workspace:*", + "bun-nshm": "workspace:*", "exoproc-utils": "workspace:*" } } diff --git a/packages/accessors/src/accessors.ts b/packages/accessors/src/accessors.ts new file mode 100644 index 0000000..bbb5706 --- /dev/null +++ b/packages/accessors/src/accessors.ts @@ -0,0 +1,293 @@ +import { Thread } from 'bun-winapi'; +import { type ISyncCallableMemoryAccessor } from 'bun-xffi'; +import { type NThreadOptions } from 'bun-nthread'; +import { NShm } from 'bun-nshm'; +import { HostAccessor } from './middleware-accessor.js'; +import { + IndirectNThreadHostAccessor, + NThreadRaceAccessor, +} from './indirect-nthread-host-accessor.js'; + +/** What {@link createAccessor}'s `id` parameter identifies. */ +export type AccessorIdType = 'thread' | 'process' | 'processAllThreadIds'; + +/** + * Default for {@link AccessorOptions.idType} when omitted -- `id` is treated + * as a pid and every one of its threads races for the hijack (see + * {@link NThreadRaceAccessor}), so callers don't have to pick a thread + * themselves. + */ +const DEFAULT_ID_TYPE: AccessorIdType = 'processAllThreadIds'; + +/** + * Options for {@link createAccessor}. + */ +export interface AccessorOptions { + /** + * Whether `id` is a thread id or a process id. Default: + * `'processAllThreadIds'` -- `id` is a pid, and this builds a genuine + * {@link IndirectNThreadHostAccessor} (via a {@link NThreadRaceAccessor} + * standing in for its `NThread`, internally -- see that class's doc + * comment) that, once initialized, races an `NThread` hijack attempt + * against *every* thread the process currently has, since there's no way + * to tell in advance which thread(s) (if any) will ever return to user + * mode (see CLAUDE.md's `ping.exe`/`DummyProcess` notes on why a thread + * parked in an indefinite kernel wait never lands the redirect). + * Whichever one's hijack lands first wins; every other candidate is + * aborted and deinitialized right away. Resolving *which* thread only + * happens inside `init()` (no `initSync()` support -- racing is + * inherently async) -- both {@link createAccessor} and + * {@link createAccessorWithoutInit} support this idType now, the latter + * just returns the accessor before racing anything (same as any other + * idType). Heavier than `'thread'`/`'process'` (one hijack attempt per + * thread, run concurrently) -- see CLAUDE.md's throughput-vs-stability + * notes on stressing a target harder. + * + * Pass `'thread'` to name one specific, already-live thread directly + * ({@link IndirectNThreadHostAccessor}) instead of racing -- the precise, + * unambiguous form when you already know which thread will work. Pass + * `'process'` to hand in a pid and let this pick the process's first + * enumerable thread ({@link Thread.getThreads}) for you, without racing + * the rest. + */ + idType?: AccessorIdType; + /** + * The `HostAccessor` class to build for the default chain. Default: + * {@link IndirectNThreadHostAccessor}. Ignored when `backend` is supplied. + * + * When `host` is left at the default, `idType: 'processAllThreadIds'` + * behaves exactly as documented there (races every thread via + * `NThreadRaceAccessor`) and `hostOptions` is forwarded as `NThreadOptions` + * to the `NThread`(s) it builds -- the same thing `nthreadOptions` used to + * do, just renamed now that this isn't `NThread`-specific. A custom `host` + * is built directly as `new host(pid, threadId, hostOptions)`; there's no + * generic way to race across an arbitrary host class's threads, so + * `idType: 'processAllThreadIds'` isn't supported for one (throws). + */ + host?: new ( + pid: number, + threadId: number, + options?: Record, + ) => HostAccessor; + /** Forwarded to `host`'s constructor. Ignored when `backend` is supplied. */ + hostOptions?: Record; + /** + * Use this accessor instead of building the default + * {@link IndirectNThreadHostAccessor} chain -- any `HostAccessor` (or + * subclass) works, e.g. an already-wired one of your own, or a plain + * custom backend wrapped in `new HostAccessor(myAccessor)`. When supplied, + * `id`/`idType` are never resolved at all. Still eligible for + * `sharedMemory` wrapping, same as the default chain. + */ + backend?: HostAccessor; + /** + * Wrap the resolved accessor (the default chain, or `backend` if + * supplied) with a shared-memory middleware: plain `READWRITE` + * allocations get backed by cross-process shared memory, so reads/writes + * against them skip the remote round-trip entirely after the initial + * `alloc()`. The middleware itself (e.g. {@link NShm}) is a plain, + * non-inittable `MiddlewareAccessor` -- {@link createAccessor}/ + * {@link createAccessorWithoutInit} wrap it in an outer `HostAccessor` so + * the returned value is always a real `HostAccessor` regardless of this + * flag. Default: `false`. + */ + sharedMemory?: boolean; + /** + * Shared-memory middleware class used when `sharedMemory` is `true`. + * Default: {@link NShm}. `options` is untyped ({@link Record}) rather than + * {@link NShmOptions} on purpose -- a custom middleware isn't required to + * share NShm's options shape at all. + */ + sharedMemoryMiddleware?: new ( + backend: ISyncCallableMemoryAccessor, + root: HostAccessor, + options?: Record, + ) => ISyncCallableMemoryAccessor; + /** + * Forwarded to `sharedMemoryMiddleware`. Ignored when `sharedMemory` is + * `false`. Usually left empty -- {@link NShm}'s constructor (the default + * `sharedMemoryMiddleware`) treats a missing/empty options object as "use + * the defaults". + */ + sharedMemoryOptions?: Record; +} + +/** Resolves `id` (a raw thread id) to its owning `{ pid, threadId }` pair. */ +function resolveFromThreadId(id: number): { pid: number; threadId: number } { + // pid=0 means "all processes" -- a bare thread id doesn't tell us which + // process owns it, so the system-wide snapshot has to be searched. + const entry = Thread.getThreads(0).find((t) => t.tid === id); + if (!entry) { + throw new Error(`createAccessor: no thread with id ${id} found`); + } + return { pid: entry.ownerPid, threadId: id }; +} + +/** Resolves `id` (a process id) to `{ pid, threadId }` via its first enumerable thread. */ +function resolveFromProcessId(id: number): { pid: number; threadId: number } { + const threadId = Thread.getThreads(id)[0]?.tid; + if (threadId === undefined) { + throw new Error(`createAccessor: process ${id} has no threads to redirect`); + } + return { pid: id, threadId }; +} + +/** The default chain or `options.backend`, before any `sharedMemory` wrapping. */ +function resolveBaseAccessor( + id: number, + options: AccessorOptions, +): HostAccessor { + if (options.backend) { + return options.backend; + } + + const Host = options.host ?? IndirectNThreadHostAccessor; + const idType = options.idType ?? DEFAULT_ID_TYPE; + + if (idType === 'processAllThreadIds') { + if (Host !== IndirectNThreadHostAccessor) { + throw new Error( + 'createAccessor: idType "processAllThreadIds" is only supported ' + + "with the default host (IndirectNThreadHostAccessor) -- there's " + + "no generic way to race across an arbitrary host class's threads.", + ); + } + return new IndirectNThreadHostAccessor( + new NThreadRaceAccessor( + id, + options.hostOptions as NThreadOptions | undefined, + ), + ); + } + + const { pid, threadId } = + idType === 'thread' ? resolveFromThreadId(id) : resolveFromProcessId(id); + + return new Host(pid, threadId, options.hostOptions); +} + +/** + * Builds the same accessor {@link createAccessor} does, without initializing + * it -- the result still lazily initializes on its first real operation + * (every {@link InittableMiddlewareAccessor} op is guarded by + * `!isInitializing -> await this.init()`), it's just not pre-initialized up + * front. Defaults to {@link IndirectNThreadHostAccessor} (thread redirection + * -- no `CreateRemoteThread` and no remote allocation for the call mechanism + * itself); pass `options.backend` to use a different strategy instead. + * + * Always returns a real {@link HostAccessor} -- `init`/`deinit`/etc. are + * directly callable on the result, `sharedMemory: true` included. Rather + * than wrapping the resolved base accessor in a new outer `HostAccessor`, + * `sharedMemory: true` splices the shared-memory middleware ({@link NShm} by + * default, a plain non-inittable `MiddlewareAccessor`) directly into the + * base accessor's own `backend` chain (`base.backend` becomes the + * middleware, whose own `backend` becomes whatever `base.backend` used to + * be) and returns `base` itself -- so the result is still the concrete + * class `resolveBaseAccessor` produced (e.g. `IndirectNThreadHostAccessor`, + * `.nthread` and all), just with allocations transparently intercepted. + * `HostAccessor.init()`/`.deinit()` skip over non-inittable middleware + * layers when walking the chain (see `InittableMiddlewareAccessor.initNext()` + * in bun-xffi), so this still reaches down and initializes/deinitializes + * the spliced-in middleware's own backend correctly. + * + * `options.idType` defaults to `'processAllThreadIds'`, which races every + * thread of the process via a {@link NThreadRaceAccessor} nested inside the + * built `IndirectNThreadHostAccessor` -- see {@link AccessorOptions.idType}. + * Building it still doesn't touch the target process (beyond a local + * `Thread.getThreads` snapshot to discover candidates) -- the actual hijack + * attempts, and picking a winner among them, only happen once `init()` runs, + * whether that's this function's caller doing it lazily on first real use, + * or {@link createAccessor} doing it eagerly. + */ +export function createAccessorWithoutInit( + id: number, + options: AccessorOptions = {}, +): HostAccessor { + const base = resolveBaseAccessor(id, options); + if (!options.sharedMemory) { + return base; + } + + // Splice the shared-memory middleware into `base`'s own backend chain + // (base.backend -> middleware -> base's original backend) and return + // `base` itself, rather than allocating a new outer HostAccessor to wrap + // it -- this keeps `base`'s concrete class/identity intact (e.g. an + // IndirectNThreadHostAccessor's `.nthread` stays reachable) while every op + // still gets intercepted by the middleware, since `base`'s own ops already + // forward to `base.backend`. + base.backend = new (options.sharedMemoryMiddleware ?? NShm)( + base.backend, + base, + options.sharedMemoryOptions, + ); + return base; +} + +/** + * {@link createAccessorWithoutInit}, followed by `await`ing the result's + * `init()` so the accessor is already initialized by the time this resolves, + * instead of initializing lazily on its first real operation. Same + * always-`HostAccessor` return type as {@link createAccessorWithoutInit}. + * + * For `options.idType === 'processAllThreadIds'` (see + * {@link AccessorOptions.idType}), this `init()` call is what actually runs + * the race ({@link NThreadRaceAccessor.onInit}, nested inside the resolved + * {@link IndirectNThreadHostAccessor}'s own chain) -- picking a winner, + * aborting the rest, and (when `sharedMemory: true`) initializing the + * shared-memory middleware on top, all as part of this one `await`. The + * result is always a genuine `IndirectNThreadHostAccessor` for every + * `idType`, `processAllThreadIds` included -- `NThreadRaceAccessor` never + * surfaces to callers; see its own doc comment. + */ +export async function createAccessor( + id: number, + options: AccessorOptions = {}, +): Promise { + const accessor = createAccessorWithoutInit(id, options); + await accessor.init(); + return accessor; +} + +/** + * How aggressively the default {@link createAccessor} chain drives the + * redirected thread: tighter polling and shorter timeouts squeeze more + * read/write/call throughput out of it (less latency added per operation), + * at the cost of stressing the target harder and raising the odds of + * destabilizing/crashing a fragile one -- see CLAUDE.md's NThread + * timeout-tuning notes for why some targets need more slack. Purely a + * throughput-vs-stability dial on NThread's own wait/poll timing (plus, + * at level 2, turning on shared memory for extra throughput). + * + * 1 = gentle: long timeout, NThread's own fast polling -- safest for a fragile/loaded target. + * 2 = balanced: NThread's own defaults (5000ms timeout, 2ms poll) + shared memory. + */ +export type AccessorAggressiveness = 1 | 2; + +const AGGRESSIVENESS_PRESETS: Record< + AccessorAggressiveness, + { hostOptions: NThreadOptions; sharedMemory: boolean } +> = { + 1: { + hostOptions: { timeoutMs: 20000, pollIntervalMs: 2 }, + sharedMemory: false, + }, + 2: { + hostOptions: { timeoutMs: 5000, pollIntervalMs: 2 }, + sharedMemory: true, + }, +}; + +/** + * Returns a ready-made {@link AccessorOptions} template tuned for the given + * {@link AccessorAggressiveness} level -- pass it straight to + * {@link createAccessor}, or spread/extend it (e.g. to also set `idType`). + */ +export function createAccessorOptions( + aggressiveness: AccessorAggressiveness = 1, +): AccessorOptions { + const preset = AGGRESSIVENESS_PRESETS[aggressiveness]; + return { + hostOptions: { ...preset.hostOptions }, + sharedMemory: preset.sharedMemory, + }; +} diff --git a/packages/accessors/src/index.ts b/packages/accessors/src/index.ts index 84fa963..c14bfa4 100644 --- a/packages/accessors/src/index.ts +++ b/packages/accessors/src/index.ts @@ -1 +1,3 @@ export * from './middleware-accessor.js'; +export * from './indirect-nthread-host-accessor.js'; +export * from './accessors.js'; diff --git a/packages/accessors/src/indirect-nthread-host-accessor.ts b/packages/accessors/src/indirect-nthread-host-accessor.ts new file mode 100644 index 0000000..02b8106 --- /dev/null +++ b/packages/accessors/src/indirect-nthread-host-accessor.ts @@ -0,0 +1,305 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { + isMiddlewareAccessor, + type ISyncCallableMemoryAccessor, + type IHostAccessor, + type IInittableAccessor, + type CFunction, + type CCallResult, +} from 'bun-xffi'; +import { Thread } from 'bun-winapi'; +import { NThread, type NThreadOptions } from 'bun-nthread'; +import { + HostAccessor, + ThrowingMemoryAccessor, + RedirectorHostAccessor, + BootstrapHostAccessor, + RacingHostAccessor, + IndirectCallRedirectorAccessor, + MachineCodePoolMiddleware, + MemsetWriteAccessor, + MemcmpReadAccessor, + FileTransferWriteAccessor, + FileTransferReadAccessor, + ScannerMiddleware, + MarshallingCallableAccessor, +} from './middleware-accessor.js'; + +/** + * A {@link HostAccessor} whose base "call" mechanism is {@link NThread} (x64 + * thread redirection) instead of `RemoteCallableMemoryAccessor` (a fresh + * `CreateRemoteThread` per call). This directly replaces the old + * `IndirectCallableAccessor` + manual `NThread`/`RedirectorHostAccessor` + * wiring -- it builds the same indirect chain + * (`IndirectCallRedirectorAccessor` → machineCode pool → memset write → + * memcmp read → file-transfer R/W → scanner → marshalling) on top of an + * `NThread`-*like* backend directly. + * + * Only the thing that actually executes remote calls changes: no + * `CreateRemoteThread` at all -- a live thread in the target is redirected, + * parked at a `jmp $` stub, and driven per call. This also sidesteps a + * GHA/Wine bug where WinAPI calls (VirtualAlloc, malloc, fopen, ...) executed + * on a freshly-created thread (local or remote) are unreliable -- see CLAUDE.md. + * + * Accepts an already-constructed `NThread`, a `(pid, threadId, options)` + * triple to build one internally, or a {@link NThreadRaceAccessor} standing + * in for a not-yet-known winner among every thread of a process (the + * `idType: 'processAllThreadIds'` support behind `createAccessor` -- see + * that class's own doc comment): + * + * const memory = new IndirectNThreadHostAccessor(pid, tid); + * const addr = await memory.alloc(64); // VirtualAlloc via redirect + * await memory.call(SomeFunc, addr); // executed on the thread + * + * const race = new NThreadRaceAccessor(pid); + * const memory = new IndirectNThreadHostAccessor(race); // built once, synchronously + * await memory.init(); // races, resolves `race` internally + * + * With the `(pid, threadId, options)` form, this accessor builds the + * `NThread`'s `RedirectorHostAccessor` root itself and wires its `target` + * to `this` -- it owns that object, nothing else could reach it. + * + * With the `backend: NThread | NThreadRaceAccessor` form, the backend (and + * whatever `root` it was constructed with) belongs to the caller. This + * accessor does not reach into its `root` to rewire it -- if its bootstrap + * stub calls need to route through this indirect chain (e.g. `root` is a + * `RedirectorHostAccessor`), the caller sets `root.target = indirect` itself + * after construction, same as building the chain by hand: + * + * const redirector = new RedirectorHostAccessor(pid); + * const nthread = new NThread(backend, tid, options, redirector); + * const indirect = new IndirectNThreadHostAccessor(nthread); + * redirector.target = indirect; + */ +export class IndirectNThreadHostAccessor extends HostAccessor { + private readonly nthreadRef: NThread | NThreadRaceAccessor; + private bootstrapRoot: BootstrapHostAccessor; + + /** + * The real `NThread` this accessor's chain ultimately runs on. When built + * from a {@link NThreadRaceAccessor} (the `idType: 'processAllThreadIds'` + * case), that accessor's own `target` (inherited from + * `RedirectorHostAccessor`) is the winning `NThread` by the time `init()` + * resolves (see its doc comment), so this drills through to it rather + * than exposing the race stand-in itself -- callers that reach past the + * generic `HostAccessor` surface into `NThread`-specific members (e.g. + * `.savedContext`, `.setContext()`) need the genuine instance. + */ + get nthread(): NThread { + return this.nthreadRef instanceof NThreadRaceAccessor + ? (this.nthreadRef.target as NThread) + : this.nthreadRef; + } + + constructor(backend: NThread | NThreadRaceAccessor); + constructor(pid: number, threadId: number, options?: NThreadOptions); + constructor( + backendOrPid: NThread | NThreadRaceAccessor | number, + threadId?: number, + options: NThreadOptions = {}, + ) { + let nthread: NThread | NThreadRaceAccessor; + let nthreadRoot: RedirectorHostAccessor | undefined; + // `typeof ... === 'number'` rather than `instanceof NThread`: NThread now + // lives in a different package than this class, so a caller's own + // cross-package-resolved `NThread` copy can fail an `instanceof` check + // here even though it's structurally identical (same root cause as + // `isMiddlewareAccessor` elsewhere in this codebase -- see its doc + // comment on why `instanceof` against a concrete class is unreliable + // across a package boundary under Wine). + if (typeof backendOrPid === 'number') { + nthreadRoot = new RedirectorHostAccessor(backendOrPid); + nthread = new NThread(backendOrPid, threadId!, options, nthreadRoot); + } else { + nthread = backendOrPid; + } + + const pid = nthread.processId; + super(new ThrowingMemoryAccessor(pid)); + + const bootstrap = new BootstrapHostAccessor(pid, this); + this.bootstrapRoot = bootstrap; + bootstrap.backend = nthread; + + const redirector = new IndirectCallRedirectorAccessor(nthread, bootstrap); + const machineCodePool = new MachineCodePoolMiddleware( + redirector, + bootstrap, + ); + const memsetWrite = new MemsetWriteAccessor(machineCodePool, bootstrap); + const memcmpRead = new MemcmpReadAccessor(memsetWrite, bootstrap); + const fileWriter = new FileTransferWriteAccessor(memcmpRead, bootstrap); + const fileReader = new FileTransferReadAccessor(fileWriter, bootstrap); + const scanner = new ScannerMiddleware(fileReader, bootstrap); + const marshalling = new MarshallingCallableAccessor(scanner, bootstrap); + + this.backend = marshalling; + let b: ISyncCallableMemoryAccessor = marshalling; + while (isMiddlewareAccessor(b)) { + b = b.backend; + } + if (b) { + this._processId = b.processId; + } + + // Only for the (pid, threadId, options) form: we built `nthreadRoot` + // ourselves above, so we're the only one who could ever wire it -- route + // its bootstrap stub calls down through this indirect chain. For the + // `backend: NThread | NThreadRaceAccessor` form, `nthreadRoot` is + // undefined here and the caller's own `root` (whatever it is) is left + // untouched -- see the class doc comment. + if (nthreadRoot) { + nthreadRoot.target = this; + } + + this.nthreadRef = nthread; + } + + protected override async onInit(): Promise { + await this.bootstrapRoot.init(); + } + + protected override onInitSync(): void { + this.bootstrapRoot.initSync(); + } +} + +/** + * Module-scoped and shared across every `NThreadRaceAccessor` instance -- + * `AsyncLocalStorage` isolates by *async execution context*, not by which + * object holds the reference, so sharing one instance across unrelated, + * concurrently-racing `NThreadRaceAccessor`s (e.g. two independent + * `createAccessor()` calls against two different processes at once) is safe + * and idiomatic, the same way e.g. Express shares one ALS instance across + * all concurrent request handlers. + */ +const currentCandidate = new AsyncLocalStorage(); + +/** + * Stands in for a not-yet-known `NThread` inside an + * {@link IndirectNThreadHostAccessor}'s own chain -- pass an instance of + * this directly as that class's `backend` (its `backend: NThread | + * NThreadRaceAccessor` constructor form). The whole `IndirectNThreadHostAccessor` + * chain gets built exactly once, synchronously, right alongside every other + * `idType`; only the actual thread hijack is deferred to `init()`, same as + * the rest of this class's own machinery. + * + * Extends {@link RacingHostAccessor} and overrides its `onInit()` to + * actually race (`this.race()`), then sets its own `target` to the winning + * `NThread` directly -- so every op this class forwards (inherited from + * `RedirectorHostAccessor`) transparently reaches the winner from then on, + * with nothing above it (in `IndirectNThreadHostAccessor`'s own chain) ever + * needing to change. + * + * Only initializes the *raw* `NThread` hijack per candidate, not a full + * `IndirectNThreadHostAccessor` chain -- that chain is built exactly once, + * around *this* accessor rather than around any particular candidate, so the + * real extra work beyond landing the hijack (`IndirectCallRedirectorAccessor`, + * `MachineCodePoolMiddleware`, an msvcrt check, opening a remote temp file for + * `FileTransferWriteAccessor`, ...) naturally only ever happens once, no + * matter which candidate wins. + * + * Every candidate `NThread` is built with `this` (the single, shared + * `NThreadRaceAccessor`) as its root directly -- no per-candidate proxy + * object. That's normally unsafe: `NThread.onInit()`'s bootstrap + * (`this.root.call(stubs.jumpStub)`, landing the hijack) needs `root.call()` + * to resolve back to *that exact candidate's* own `call()`, and with N + * candidates racing concurrently through one shared root, a single mutable + * `backend`/`target` field can't hold N different answers at once (tried + * this with a plain shared root first -- every candidate's bootstrap call + * collided on the same placeholder and threw). The fix is `currentCandidate` + * above: `startRacer` (below) wraps each candidate's `init()` in + * `currentCandidate.run(candidate, ...)`, which threads that candidate + * through its *entire* async continuation (every `await` inside its own + * `onInit()`) in a context isolated from every other concurrently-racing + * candidate. `call()` (below), when `target` is still self (race not yet + * decided), reads `currentCandidate.getStore()` to find out which + * candidate's own bootstrap call this is and forwards to *its* `call()` + * directly -- no shared field involved. This keeps the ALS mechanism + * entirely local to this class (`RacingHostAccessor` itself knows nothing + * about it, see its doc comment) and needs zero changes to `NThread` -- + * `this.root.call(...)` there is untouched, so root-indirection stays a + * generic, reusable mechanism, not something hardcoded around racing. + */ +export class NThreadRaceAccessor extends RacingHostAccessor { + private readonly aborts = new Map void>(); + + constructor( + pid: number, + nthreadOptions: NThreadOptions = {}, + root?: IHostAccessor, + ) { + super(pid, root); + // RedirectorHostAccessor's own constructor sets `target` to a fresh + // placeholder `ThrowingHostAccessor`, never to `this` -- only + // `BootstrapHostAccessor` self-loops that way. `call()` below needs + // `target === this` to mean "race not yet decided", so set it + // explicitly here too. + this.target = this; + + const threads = Thread.getThreads(pid); + if (threads.length === 0) { + throw new Error( + `createAccessor: process ${pid} has no threads to redirect`, + ); + } + + const callerSignal = nthreadOptions.signal; + for (const t of threads) { + const controller = new AbortController(); + // Compose with a caller-supplied signal (if any) -- either one aborts this candidate. + if (callerSignal) { + if (callerSignal.aborted) controller.abort(callerSignal.reason); + else + callerSignal.addEventListener( + 'abort', + () => controller.abort(callerSignal.reason), + { once: true }, + ); + } + + // root = this directly -- auto-registers as a racer via + // MiddlewareAccessor's constructor (root.registerChild(this)); see + // the class doc comment for why a shared root is safe here only + // because of startRacer()'s AsyncLocalStorage wrapping below. + const nthread = new NThread( + pid, + t.tid, + { ...nthreadOptions, signal: controller.signal }, + this, + ); + this.aborts.set(nthread, () => controller.abort()); + } + } + + protected override startRacer(racer: IInittableAccessor): Promise { + return currentCandidate.run(racer as unknown as NThread, () => + racer.init(), + ); + } + + override async call(func: CFunction, ...args: any[]): Promise { + if (this.target === this) { + const caller = currentCandidate.getStore(); + if (caller) return caller.call(func, ...args); + } + return super.call(func, ...args); + } + + protected override releaseLoser( + racer: IInittableAccessor, + settled: Promise, + ): void { + this.aborts.get(racer as unknown as NThread)?.(); + super.releaseLoser(racer, settled); + } + + protected override async onInit(): Promise { + const winner = await this.race(); + this.target = winner as unknown as NThread; + } + + protected override onInitSync(): never { + throw new Error('NThreadRaceAccessor does not support initSync()'); + } +} diff --git a/packages/accessors/src/middleware-accessor.ts b/packages/accessors/src/middleware-accessor.ts index 722aeb5..ec13cae 100644 --- a/packages/accessors/src/middleware-accessor.ts +++ b/packages/accessors/src/middleware-accessor.ts @@ -43,10 +43,15 @@ import { isModuleLoadedInProcessSync, MiddlewareAccessor, InittableMiddlewareAccessor, - isMiddlewareAccessor, + isInittableAccessor, + HostAccessor, type IHostAccessor, + type IInittableAccessor, + type IMiddlewareAccessor, } from 'bun-xffi'; +export { HostAccessor }; + export abstract class MsvcrtDependentMiddlewareAccessor extends InittableMiddlewareAccessor { protected async onInit(): Promise { if (this.isLocal) return; @@ -67,43 +72,6 @@ export abstract class MsvcrtDependentMiddlewareAccessor extends InittableMiddlew } } -/** - * HostAccessor is a base class that automatically initializes all nested InittableMiddlewareAccessors - * in the backend decorator chain. - */ -export class HostAccessor extends InittableMiddlewareAccessor { - override get processId(): number { - return this._processId; - } - - constructor(backend: ISyncCallableMemoryAccessor, root?: IHostAccessor) { - super(backend, root ?? (null as any)); - if (!root) { - (this as any).root = this; - } - let b: ISyncCallableMemoryAccessor = backend; - while (isMiddlewareAccessor(b)) { - b = b.backend; - } - if (b) { - this._processId = b.processId; - } - } - - protected override async onInit(): Promise { - // No-op. Chain initialization is automatically propagated by init(). - } - - protected override onInitSync(): void { - // No-op. Chain initialization is automatically propagated by initSync(). - } - - // deinit()/deinitSync() are inherited as-is from InittableMiddlewareAccessor: - // deinitNext()/deinitNextSync() already walk the whole `backend` chain and - // deinit every InittableMiddlewareAccessor on it, so there's nothing left - // for HostAccessor to reconcile separately. -} - /** * A HostAccessor that forwards all operations to a dynamically changeable target HostAccessor. * This is useful for resolving circular dependencies in middleware chains where the root @@ -132,7 +100,15 @@ export class RedirectorHostAccessor extends HostAccessor { constructor(processId: number, root?: IHostAccessor) { super(new ThrowingMemoryAccessor(processId), root); - this._target = new ThrowingHostAccessor(processId, this.root); + // Deliberately self-rooted (no `root` arg) rather than `this.root`: this + // placeholder is never a real candidate, but constructing it with the + // outer `root` would still run through `MiddlewareAccessor`'s + // constructor -- which unconditionally calls `root.registerChild(this)` + // -- and register the placeholder itself as a racer on a + // `RacingHostAccessor` root. Since it always throws and never does real + // work, it would "win" the race instantly, before any genuine candidate + // could land. + this._target = new ThrowingHostAccessor(processId); } get target(): IHostAccessor { @@ -352,11 +328,138 @@ export class RedirectorHostAccessor extends HostAccessor { // No-op. The target is initialized separately. } + /** + * Mirrors the `close()` override above for the async lifecycle: every op + * (read/write/call/...) routes to `target` once it's no longer `this`, so + * `deinit()` must too. Without this, the inherited `deinitNext()` (from + * `InittableMiddlewareAccessor`) walks `this.backend` -- which for a + * subclass like `NThreadRaceAccessor` stays pointed at its own + * placeholder `ThrowingMemoryAccessor` forever (only `target` ever gets + * reassigned to the winner, per `RedirectorHostAccessor`'s whole design; + * see its class doc comment) -- so the real accessor `target` resolved to + * would never actually get deinited, leaving its underlying resource + * (e.g. a hijacked thread) permanently stuck. + * + * Deliberately excludes `t === this.root`: `BootstrapHostAccessor.onInit()` + * itself resolves `target` to `this.root` once its own backend has landed + * -- that's a hand-off back to whatever *contains* this accessor (e.g. a + * per-candidate `BootstrapHostAccessor` inside `NThreadRaceAccessor` + * resolves to the race accessor itself), not a distinct resource this + * accessor now owns. Cascading `deinit()` there would tear down the + * container (and everything else riding on it, e.g. the actual race + * winner) from a single losing candidate's own cleanup. + */ + protected override async onDeinit(): Promise { + const t = this.getTarget(); + if (t !== this && t !== this.root && isInittableAccessor(t)) { + await t.deinit(); + } + } + protected override onInitSync(): void { // No-op. The target is initialized separately. } } +/** + * A minimal `RedirectorHostAccessor` mixin meant to be *extended*, not used + * directly -- its only job is giving a subclass (e.g. `NThreadRaceAccessor`) + * two things for free: automatic candidate collection, and a `race()` helper + * to actually run the race once the subclass's own `onInit()` decides it's + * time. `target` (inherited) is left for the subclass to set to the winner + * however makes sense for it -- this class never touches it itself. + * + * Candidates are never added explicitly -- every `MiddlewareAccessor`'s + * constructor calls `root.registerChild(this)` (a no-op on every other + * `IHostAccessor`; see its doc comment), so simply constructing a candidate + * with a `RacingHostAccessor` (sub)instance as its `root` is enough to enter + * it into the race. + * + * Deliberately knows nothing about *why* a caller is racing candidates -- no + * `NThread`, no `AbortController`, no timeout, no `AsyncLocalStorage`; a + * caller that needs to cancel the losers does so itself, by extending this + * class and overriding `releaseLoser`, and a caller whose candidates need + * per-candidate context threaded through their own `init()` (e.g. + * `NThreadRaceAccessor`, whose candidates all share one root and need + * `root.call()` to resolve back to *this specific* candidate while racing) + * does so by overriding `startRacer` -- kept as a single, narrow extension + * point instead of baking any particular mechanism (like + * `AsyncLocalStorage`) into this generic base. + */ +export class RacingHostAccessor extends RedirectorHostAccessor { + protected readonly racers: IInittableAccessor[] = []; + + override registerChild(child: IMiddlewareAccessor): void { + // Can fire before our own field initializers run: RedirectorHostAccessor's + // constructor builds a placeholder `new ThrowingHostAccessor(processId, + // this.root)`, and `this.root` is this very instance (self-rooted, still + // mid-construction) when nothing else was passed as `root` -- `this.racers` + // isn't assigned yet at that point. Harmless to skip: real candidates + // always register later, from a subclass's own constructor body, which + // only runs after every `super()` call (and therefore every field + // initializer) has completed. + if (!this.racers || !isInittableAccessor(child)) return; + this.racers.push(child); + } + + /** + * Launches one racer's `init()`. Overridable so a subclass can wrap the + * call with its own per-candidate context (see `NThreadRaceAccessor`, + * which threads an `AsyncLocalStorage` context through this so its shared + * root's `call()` can resolve back to whichever candidate is currently + * bootstrapping) without this base class needing to know why. + */ + protected startRacer(racer: IInittableAccessor): Promise { + return racer.init(); + } + + /** + * Races every registered racer's `init()` and returns whichever settles + * first; every other racer gets {@link releaseLoser}'d. Doesn't touch + * `target` itself -- callers (a subclass's own `onInit()` override) + * decide what to do with the winner. + */ + protected async race(): Promise { + if (this.racers.length === 0) { + throw new Error('RacingHostAccessor: no racers registered to race'); + } + const attempts = this.racers.map((racer) => ({ + racer, + settled: this.startRacer(racer), + })); + let winner: IInittableAccessor; + try { + winner = await Promise.any( + attempts.map((a) => a.settled.then(() => a.racer)), + ); + } catch (err) { + throw new Error( + `RacingHostAccessor: none of ${attempts.length} racer(s) could be initialized`, + { cause: err }, + ); + } + for (const a of attempts) { + if (a.racer === winner) continue; + this.releaseLoser(a.racer, a.settled); + } + return winner; + } + + /** + * Called once per losing racer, right after a winner is picked. Default: + * deinit it once (if) its own `init()` eventually settles -- `deinit()` is + * a no-op for one that never finished or that already failed. Subclasses + * (e.g. `NThreadRaceAccessor`) can override this to *also* cancel an + * in-flight racer immediately, instead of only cleaning up after the fact. + */ + protected releaseLoser( + racer: IInittableAccessor, + settled: Promise, + ): void { + settled.then(() => racer.deinit().catch(() => {})).catch(() => {}); + } +} + /** * BootstrapHostAccessor is a specialized RedirectorHostAccessor designed to resolve circular dependencies * during the initialization of decorator/middleware chains. diff --git a/packages/dummy/src/dummy.ts b/packages/dummy/src/dummy.ts index 30ee6ca..2b61ecf 100644 --- a/packages/dummy/src/dummy.ts +++ b/packages/dummy/src/dummy.ts @@ -1,9 +1,12 @@ +import { ptr } from 'bun:ffi'; import { Kernel32Impl, Advapi32Impl, TokenAccess, CreateRestrictedTokenFlags, ProcessCreationFlags, + StartupInfoFlags, + ShowWindowCommand, StartupInfoA, ProcessInformation, } from 'bun-xffi'; @@ -13,6 +16,15 @@ export interface DummyProcessOptions { executable?: string; /** Arguments for `executable` (default: an effectively-infinite ping). */ args?: string[]; + /** + * Spawn onto the interactive desktop with a real, visible window instead + * of the default headless/no-window spawn (default `false`). Needed for + * anything a human has to actually click into and type at (e.g. the + * `notepad-keystroke-hook` example) -- most callers (tests, most examples) + * want the default: `CREATE_NO_WINDOW` plus no explicit `lpDesktop`, so + * nothing pops up during automated/headless runs. + */ + visible?: boolean; } interface SpawnedProcess { @@ -28,6 +40,7 @@ interface SpawnedProcess { export type SpawnStrategy = ( executable: string, args: string[], + visible?: boolean, ) => SpawnedProcess; /** @@ -78,7 +91,7 @@ export class DummyProcess { const executable = options.executable ?? 'ping.exe'; const args = options.args ?? ['127.0.0.1', '-n', '1000000']; - const spawned = spawnStrategy(executable, args); + const spawned = spawnStrategy(executable, args, options.visible); this.pid = spawned.pid; this.handle = spawned.handle; @@ -112,13 +125,30 @@ export class DummyProcess { } } -function spawnDeElevated(executable: string, args: string[]): SpawnedProcess { +function spawnDeElevated( + executable: string, + args: string[], + visible = false, +): SpawnedProcess { const commandLine = `"${executable}"${args.length ? ` ${args.join(' ')}` : ''}`; const commandLineBuf = Buffer.concat([ Buffer.from(commandLine + '\0', 'latin1'), Buffer.alloc(32), ]); + // `CreateProcessAsUserA` with a token derived via `CreateRestrictedToken` + // doesn't reliably land the spawned process on the caller's own + // interactive window station/desktop unless `lpDesktop` says so + // explicitly -- an unset (null) `lpDesktop` leaves that up to the OS's own + // default, which isn't guaranteed to be the visible desktop. "winsta0" + // is always the interactive window station; "default" is its default + // desktop -- this is the standard fix for "spawned process's window never + // shows up" with CreateProcessAsUser*. Kept alive for the whole call + // (referenced by `startupInfo.lpDesktop` below via its native address). + const desktopBuf = visible + ? Buffer.from('winsta0\\default\0', 'latin1') + : null; + const tokenOut = Buffer.alloc(8); const gotToken = Advapi32Impl.OpenProcessToken( Kernel32Impl.GetCurrentProcess(), @@ -162,7 +192,7 @@ function spawnDeElevated(executable: string, args: string[]): SpawnedProcess { startupInfo.assign({ cb: StartupInfoA.computed.totalSize, lpReserved: null, - lpDesktop: null, + lpDesktop: desktopBuf ? ptr(desktopBuf) : null, lpTitle: null, dwX: 0, dwY: 0, @@ -171,8 +201,8 @@ function spawnDeElevated(executable: string, args: string[]): SpawnedProcess { dwXCountChars: 0, dwYCountChars: 0, dwFillAttribute: 0, - dwFlags: 0, - wShowWindow: 0, + dwFlags: visible ? StartupInfoFlags.USESHOWWINDOW : 0, + wShowWindow: visible ? ShowWindowCommand.SW_SHOWNORMAL : 0, cbReserved2: 0, lpReserved2: null, hStdInput: 0n, @@ -195,7 +225,7 @@ function spawnDeElevated(executable: string, args: string[]): SpawnedProcess { 0, 0, 0, - ProcessCreationFlags.CREATE_NO_WINDOW, + visible ? 0 : ProcessCreationFlags.CREATE_NO_WINDOW, 0, 0, startupInfo, diff --git a/packages/exoproc/src/index.ts b/packages/exoproc/src/index.ts index d1f6a01..ecf1cf9 100644 --- a/packages/exoproc/src/index.ts +++ b/packages/exoproc/src/index.ts @@ -10,3 +10,7 @@ export * from 'exoproc-utils'; // Disambiguate duplicate errors between bun-nhook and bun-minhook export { HookAlreadyEnabledError, HookNotEnabledError } from 'bun-minhook'; + +// Disambiguate HostAccessor: canonical home is bun-xffi, exoproc-accessors +// only re-exports it for its own package's backward-compat surface. +export { HostAccessor } from 'bun-xffi'; diff --git a/packages/nhook/package.json b/packages/nhook/package.json index f66fbac..b090858 100644 --- a/packages/nhook/package.json +++ b/packages/nhook/package.json @@ -45,6 +45,7 @@ "bun-winapi": "workspace:*", "bun-xffi": "workspace:*", "bun-nthread": "workspace:*", + "exoproc-accessors": "workspace:*", "exoproc-utils": "workspace:*", "bun-capstone": "workspace:*" } diff --git a/packages/nhook/src/nhook.ts b/packages/nhook/src/nhook.ts index 8f4b6e7..5c03903 100644 --- a/packages/nhook/src/nhook.ts +++ b/packages/nhook/src/nhook.ts @@ -20,7 +20,8 @@ import { type X86Operand, X86_REG_TO_CONTEXT_NAME, } from 'bun-capstone'; -import { IndirectNThreadHostAccessor, getRandomSpinStub } from 'bun-nthread'; +import { getRandomSpinStub } from 'bun-nthread'; +import { IndirectNThreadHostAccessor } from 'exoproc-accessors'; import { log } from './logger.js'; import { ProcessExitedError } from './errors.js'; @@ -50,6 +51,22 @@ export interface NHookOptions { enabled?: boolean; } +/** + * `memory`'s own driving thread id, if it has one -- e.g. an + * `IndirectNThreadHostAccessor` backed by a real `NThread` (as + * `createAccessor` produces) permanently redirects one specific thread of + * the target process. Duck-typed (`.nthread.threadId`) rather than an + * `instanceof IndirectNThreadHostAccessor` check: `memory` may have been + * built by a different copy of that class than the one this package + * imports (cross-package `instanceof` is unreliable under Wine's isolated + * linker -- see CLAUDE.md). + */ +function getDrivingThreadId(memory: IMemoryAccessor): number | undefined { + const threadId = (memory as { nthread?: { threadId?: unknown } }).nthread + ?.threadId; + return typeof threadId === 'number' ? threadId : undefined; +} + /** * Result of a successful NHook hit. */ @@ -143,8 +160,20 @@ export class NHook if (hook.enabled) return; const addr = hook.address; - // Enumerate and handle threads - const threadIds = Native.Thread.getThreads(this.pid); + // Enumerate and handle threads -- excluding memory's own driving thread + // (if any). Suspending it here too would conflict with SetThreadContext + // + ResumeThread cycles memory.protect()/write() issue through that same + // thread below: this SuspendThread (via a separate handle) and NThread's + // own ResumeThread (via its own handle) both act on the one shared + // OS-level suspend count, but neither side knows about the other's call, + // so the net count never reaches 0 and the redirected call inside + // memory.protect() never lands -- CallTimeoutError. That thread doesn't + // need this suspend anyway: it's already fully under our control + // (parked/redirected by NThread), never executing the target's own code. + const drivingThreadId = getDrivingThreadId(memory); + const threadIds = Native.Thread.getThreads(this.pid).filter( + (t) => t.tid !== drivingThreadId, + ); const isLocal = this.pid === Native.currentProcess.pid; try { @@ -201,8 +230,14 @@ export class NHook // 1. Catch threads spinning at EB FE const pendingHits = await this.poll(); - // 2. Enumerate and handle all threads to ensure safe restoration - const threadIds = Native.Thread.getThreads(this.pid); + // 2. Enumerate and handle all threads to ensure safe restoration -- + // excluding memory's own driving thread (if any); see enable()'s + // comment on getDrivingThreadId() for why suspending it here would + // deadlock the very memory.protect()/write() calls below. + const drivingThreadId = getDrivingThreadId(memory); + const threadIds = Native.Thread.getThreads(this.pid).filter( + (t) => t.tid !== drivingThreadId, + ); const isLocal = this.pid === Native.currentProcess.pid; try { diff --git a/packages/nshm/package.json b/packages/nshm/package.json index 7d5c6f9..d7bac3f 100644 --- a/packages/nshm/package.json +++ b/packages/nshm/package.json @@ -43,7 +43,6 @@ }, "dependencies": { "bun-xffi": "workspace:*", - "exoproc-accessors": "workspace:*", "exoproc-dummy": "workspace:*", "exoproc-utils": "workspace:*" } diff --git a/packages/nshm/src/nshm.ts b/packages/nshm/src/nshm.ts index 598d58e..130078a 100644 --- a/packages/nshm/src/nshm.ts +++ b/packages/nshm/src/nshm.ts @@ -11,8 +11,8 @@ import { type AddressLike, type ICallableMemoryAccessor, type ISyncCallableMemoryAccessor, + type IHostAccessor, } from 'bun-xffi'; -import { type HostAccessor } from 'exoproc-accessors'; import { type DummyProcess, getGlobalDummyProcess as getSharedDummyProcess, @@ -211,7 +211,7 @@ export class NShm extends MiddlewareAccessor { constructor( backend: ISyncCallableMemoryAccessor, - root: HostAccessor, + root: IHostAccessor, private readonly options: NShmOptions = {}, ) { super(backend, root); diff --git a/packages/nthread/package.json b/packages/nthread/package.json index 68e933f..34ea02e 100644 --- a/packages/nthread/package.json +++ b/packages/nthread/package.json @@ -45,7 +45,6 @@ "dependencies": { "bun-winapi": "workspace:*", "bun-xffi": "workspace:*", - "exoproc-accessors": "workspace:*", "exoproc-utils": "workspace:*" } } diff --git a/packages/nthread/src/index.ts b/packages/nthread/src/index.ts index 896711e..bf9f472 100644 --- a/packages/nthread/src/index.ts +++ b/packages/nthread/src/index.ts @@ -1,4 +1,3 @@ export * from './errors.js'; export * from './nthread.js'; -export * from './indirect-nthread-host-accessor.js'; export * from './stubs.js'; diff --git a/packages/nthread/src/indirect-nthread-host-accessor.ts b/packages/nthread/src/indirect-nthread-host-accessor.ts deleted file mode 100644 index f157a49..0000000 --- a/packages/nthread/src/indirect-nthread-host-accessor.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - isMiddlewareAccessor, - type ISyncCallableMemoryAccessor, -} from 'bun-xffi'; -import { - HostAccessor, - ThrowingMemoryAccessor, - RedirectorHostAccessor, - BootstrapHostAccessor, - IndirectCallRedirectorAccessor, - MachineCodePoolMiddleware, - MemsetWriteAccessor, - MemcmpReadAccessor, - FileTransferWriteAccessor, - FileTransferReadAccessor, - ScannerMiddleware, - MarshallingCallableAccessor, -} from 'exoproc-accessors'; -import { NThread, type NThreadOptions } from './nthread.js'; - -/** - * A {@link HostAccessor} whose base "call" mechanism is {@link NThread} (x64 - * thread redirection) instead of `RemoteCallableMemoryAccessor` (a fresh - * `CreateRemoteThread` per call). This directly replaces the old - * `IndirectCallableAccessor` + manual `NThread`/`RedirectorHostAccessor` - * wiring -- it builds the same indirect chain - * (`IndirectCallRedirectorAccessor` → machineCode pool → memset write → - * memcmp read → file-transfer R/W → scanner → marshalling) on top of an - * `NThread` backend directly. - * - * Only the thing that actually executes remote calls changes: no - * `CreateRemoteThread` at all -- a live thread in the target is redirected, - * parked at a `jmp $` stub, and driven per call. This also sidesteps a - * GHA/Wine bug where WinAPI calls (VirtualAlloc, malloc, fopen, ...) executed - * on a freshly-created thread (local or remote) are unreliable -- see CLAUDE.md. - * - * Accepts either an already-constructed `NThread`, or a bare - * `(pid, threadId, options)` triple to build the `NThread` internally: - * - * const memory = new IndirectNThreadHostAccessor(pid, tid); - * const addr = await memory.alloc(64); // VirtualAlloc via redirect - * await memory.call(SomeFunc, addr); // executed on the thread - * - * With the `(pid, threadId, options)` form, this accessor builds the - * `NThread`'s `RedirectorHostAccessor` root itself and wires its `target` - * to `this` -- it owns that object, nothing else could reach it. - * - * With the `backend: NThread` form, the `NThread` (and whatever `root` it - * was constructed with) belongs to the caller. This accessor does not reach - * into `nthread.root` to rewire it -- if `nthread`'s bootstrap stub calls - * need to route through this indirect chain (e.g. `root` is a - * `RedirectorHostAccessor`), the caller sets `root.target = indirect` itself - * after construction, same as building the chain by hand: - * - * const redirector = new RedirectorHostAccessor(pid); - * const nthread = new NThread(backend, tid, options, redirector); - * const indirect = new IndirectNThreadHostAccessor(nthread); - * redirector.target = indirect; - */ -export class IndirectNThreadHostAccessor extends HostAccessor { - public readonly nthread: NThread; - private bootstrapRoot: BootstrapHostAccessor; - - constructor(backend: NThread); - constructor(pid: number, threadId: number, options?: NThreadOptions); - constructor( - backendOrPid: NThread | number, - threadId?: number, - options: NThreadOptions = {}, - ) { - let nthread: NThread; - let nthreadRoot: RedirectorHostAccessor | undefined; - if (backendOrPid instanceof NThread) { - nthread = backendOrPid; - } else { - nthreadRoot = new RedirectorHostAccessor(backendOrPid); - nthread = new NThread(backendOrPid, threadId!, options, nthreadRoot); - } - - const pid = nthread.processId; - super(new ThrowingMemoryAccessor(pid)); - - const bootstrap = new BootstrapHostAccessor(pid, this); - this.bootstrapRoot = bootstrap; - bootstrap.backend = nthread; - - const redirector = new IndirectCallRedirectorAccessor(nthread, bootstrap); - const machineCodePool = new MachineCodePoolMiddleware( - redirector, - bootstrap, - ); - const memsetWrite = new MemsetWriteAccessor(machineCodePool, bootstrap); - const memcmpRead = new MemcmpReadAccessor(memsetWrite, bootstrap); - const fileWriter = new FileTransferWriteAccessor(memcmpRead, bootstrap); - const fileReader = new FileTransferReadAccessor(fileWriter, bootstrap); - const scanner = new ScannerMiddleware(fileReader, bootstrap); - const marshalling = new MarshallingCallableAccessor(scanner, bootstrap); - - this.backend = marshalling; - let b: ISyncCallableMemoryAccessor = marshalling; - while (isMiddlewareAccessor(b)) { - b = b.backend; - } - if (b) { - this._processId = b.processId; - } - - // Only for the (pid, threadId, options) form: we built `nthreadRoot` - // ourselves above, so we're the only one who could ever wire it -- route - // its bootstrap stub calls down through this indirect chain. For the - // `backend: NThread` form, `nthreadRoot` is undefined here and the - // caller's own `root` (whatever it is) is left untouched -- see the - // class doc comment. - if (nthreadRoot) { - nthreadRoot.target = this; - } - - this.nthread = nthread; - } - - protected override async onInit(): Promise { - await this.bootstrapRoot.init(); - } - - protected override onInitSync(): void { - this.bootstrapRoot.initSync(); - } -} diff --git a/packages/nthread/src/nthread.ts b/packages/nthread/src/nthread.ts index 3cbdd5a..72ab840 100644 --- a/packages/nthread/src/nthread.ts +++ b/packages/nthread/src/nthread.ts @@ -12,14 +12,15 @@ import { WaitReturn, resolveAddress, stackAlign16, + type IHostAccessor, } from 'bun-xffi'; -import { HostAccessor } from 'exoproc-accessors'; import { getRandomSpinStub, getRandomPushretStub, getRandomJumpStub, getRandomRetStub, getRandomAddRsp28RetStub, + whenStubsReady, type ThreadStubs, } from './stubs.js'; import * as Native from 'bun-winapi'; @@ -63,10 +64,8 @@ const CTX_FLAGS = export interface NThreadOptions { /** Maximum ms to wait for the operation to complete. Default: 5000. */ timeoutMs?: number; - /** Poll interval (ms) used during redirection wait. Default: 50. */ + /** Poll interval (ms) used during redirection wait. Default: 2. */ pollIntervalMs?: number; - /** Whether to automatically suspend/resume when fetching or applying context. Default: true. */ - autoSuspend?: boolean; /** Optional cancellation signal. */ signal?: AbortSignal; } @@ -96,7 +95,6 @@ export class NThread extends InittableMiddlewareAccessor { public callRsp: bigint = 0n; public expectedRsp: bigint = 0n; public pollIntervalMs: number = 2; - public autoSuspend: boolean; public stubs: Partial = {}; public debug = false; @@ -106,16 +104,20 @@ export class NThread extends InittableMiddlewareAccessor { backend: ISyncCallableMemoryAccessor | number, public readonly threadId: number, public readonly options: NThreadOptions = {}, - root: HostAccessor, + root: IHostAccessor, ) { const actualBackend = typeof backend === 'number' ? new RemoteCallableMemoryAccessor(backend) : backend; super(actualBackend, root); - this.autoSuspend = options.autoSuspend ?? false; } + // No-op -- see IHostAccessor.registerChild's doc comment. NThread is only + // ever used as an IHostAccessor (e.g. a RedirectorHostAccessor's `target`), + // never as a racing host itself, so it has nothing to do with a child. + registerChild(): void {} + /** The OS thread ID of the redirected thread. Only valid once initialized. */ get tid(): number { return this.nativeThread!.tid; @@ -207,25 +209,23 @@ export class NThread extends InittableMiddlewareAccessor { } } + // Deliberately never suspends around fetch/apply -- NThread suspends + // exactly once, during onInit()'s landing sequence, and resumes once + // (via that same landing call's own resumeThread()); every op after that + // runs against the thread while it's parked (running) at the spin stub, + // relying on EB FE ('jmp $') never touching registers/memory between + // instructions for this to be safe. A caller-configurable "auto-suspend + // around every fetch/apply" option used to exist here but only ever added + // extra suspend/resume cycles beyond that single setup one -- exactly the + // kind of accounting an external suspend (e.g. NHook.enable()'s own + // SuspendThread on the same thread) can't see or coordinate with. fetchContext(): void { - const shouldSuspend = this.autoSuspend && this.suspendCount === 0; - if (shouldSuspend) this.suspendThread(); - try { - this.ctx.fetch(CTX_FLAGS); - this._ctxFetched = true; - } finally { - if (shouldSuspend) this.resumeThread(); - } + this.ctx.fetch(CTX_FLAGS); + this._ctxFetched = true; } applyContext(): void { - const shouldSuspend = this.autoSuspend && this.suspendCount === 0; - if (shouldSuspend) this.suspendThread(); - try { - this.ctx.apply(); - } finally { - if (shouldSuspend) this.resumeThread(); - } + this.ctx.apply(); } private suspendThread(): number { @@ -398,6 +398,15 @@ export class NThread extends InittableMiddlewareAccessor { // ── Accessor lifecycle ────────────────────────────────────────────────── protected override async onInit(): Promise { + // The stub descriptors (spin/pushRet/jump/ret/addRsp28Ret) start their + // background scans eagerly at module load, but scanning takes real time + // (walking kernel32/ntdll/kernelbase's executable sections). A caller + // that reaches onInit() soon after process start -- before those scans + // finish -- would otherwise get a misleading NoSleepAddressError/etc. + // below (getRandomXStub() swallows the real "still scanning" error and + // returns undefined, indistinguishable from "genuinely not found"). + await whenStubsReady(); + if (this.options.pollIntervalMs) { const pollIntervalMs = Math.max(0, this.options.pollIntervalMs); this.pollIntervalMs = pollIntervalMs; diff --git a/packages/nthread/src/stubs.ts b/packages/nthread/src/stubs.ts index bfabfe9..6c11bdd 100644 --- a/packages/nthread/src/stubs.ts +++ b/packages/nthread/src/stubs.ts @@ -201,6 +201,32 @@ const _jumpDescriptors = new Map([ ], ]); +// --------------------------------------------------------------------------- +// Readiness +// --------------------------------------------------------------------------- + +/** + * Resolves once every registered stub descriptor's background scan has + * completed -- every `getRandomXStub()` below is safe to call synchronously + * after this resolves. Each `getRandomXStub()` swallows `StubNotReadyError` + * (and `NoStubFoundError`) via its own try/catch and just returns + * `undefined`, indistinguishable from "genuinely not found" -- so a caller + * that races ahead of the scan (e.g. a short script that reaches + * `NThread.onInit()` moments after process start, before these + * process-wide, eagerly-started background scans have had time to finish) + * gets a misleading `NoSleepAddressError`/etc. instead of the real + * "still scanning" cause. Awaiting this first removes that race entirely. + */ +export function whenStubsReady(): Promise { + return Promise.all([ + _sleepDescriptor.whenReady(), + _retDescriptor.whenReady(), + _addRsp28RetDescriptor.whenReady(), + ...Array.from(_pushRetDescriptors.values(), (d) => d.whenReady()), + ...Array.from(_jumpDescriptors.values(), (d) => d.whenReady()), + ]).then(() => undefined); +} + // --------------------------------------------------------------------------- // Public accessors — synchronous return, no wait // --------------------------------------------------------------------------- diff --git a/packages/xffi/src/middleware-accessor.ts b/packages/xffi/src/middleware-accessor.ts index 3f1e10d..cb69604 100644 --- a/packages/xffi/src/middleware-accessor.ts +++ b/packages/xffi/src/middleware-accessor.ts @@ -9,17 +9,26 @@ import { type Pattern } from './win/scanner.js'; /** * Minimal surface `MiddlewareAccessor`/`InittableMiddlewareAccessor` (and - * `DebugMemoryAccessor`) need from a `root` accessor. The concrete - * `HostAccessor` class (and its whole family -- `RedirectorHostAccessor`, - * `BootstrapHostAccessor`, etc.) lives in `exoproc-accessors`, a package that - * depends on `bun-xffi`; typing `root` as the concrete class here would make - * `bun-xffi` depend back on `exoproc-accessors`, a cycle. `HostAccessor` - * structurally satisfies this interface already (it extends - * `InittableMiddlewareAccessor`, which provides every `ISyncCallableMemoryAccessor` - * method), so nothing downstream needs to change to conform to it. + * `DebugMemoryAccessor`) need from a `root` accessor. `HostAccessor` itself + * lives here too (below); the rest of its family that's specific to the + * default indirect-call chain (`RedirectorHostAccessor`, + * `BootstrapHostAccessor`, `RaceHostAccessor`, etc.) lives in + * `exoproc-accessors` instead, extending `HostAccessor` across the package + * boundary. This interface still exists as the structural contract `root` + * is typed against everywhere in this file, independent of which concrete + * subclass is actually passed in. */ export interface IHostAccessor extends ISyncCallableMemoryAccessor { close(): void; + /** + * Called by every `MiddlewareAccessor`'s constructor with itself as `this` + * host is its `root` -- a no-op for ordinary hosts, but lets a host that + * cares (e.g. a class that races several candidates for whichever + * initializes first) discover its whole subtree automatically, just by + * being passed as `root` when those candidates are built, instead of every + * caller having to separately register each one by hand. + */ + registerChild(child: IMiddlewareAccessor): void; } /** @@ -112,6 +121,7 @@ export class MiddlewareAccessor extends AbstractSyncMemoryAccessor { super(pid); this.backend = backend; this.root = root; + if (root) root.registerChild(this); } override enableDebug(): void { @@ -695,6 +705,48 @@ export abstract class InittableMiddlewareAccessor extends MiddlewareAccessor { } } +/** + * HostAccessor is a base class that automatically initializes all nested InittableMiddlewareAccessors + * in the backend decorator chain. + */ +export class HostAccessor extends InittableMiddlewareAccessor { + override get processId(): number { + return this._processId; + } + + constructor(backend: ISyncCallableMemoryAccessor, root?: IHostAccessor) { + super(backend, root ?? (null as any)); + if (!root) { + (this as any).root = this; + } + let b: ISyncCallableMemoryAccessor = backend; + while (isMiddlewareAccessor(b)) { + b = b.backend; + } + if (b) { + this._processId = b.processId; + } + } + + protected override async onInit(): Promise { + // No-op. Chain initialization is automatically propagated by init(). + } + + protected override onInitSync(): void { + // No-op. Chain initialization is automatically propagated by initSync(). + } + + // No-op by default -- see IHostAccessor.registerChild's doc comment. + // RaceHostAccessor (exoproc-accessors) is the only override that does + // anything with it. + registerChild(_child: IMiddlewareAccessor): void {} + + // deinit()/deinitSync() are inherited as-is from InittableMiddlewareAccessor: + // deinitNext()/deinitNextSync() already walk the whole `backend` chain and + // deinit every InittableMiddlewareAccessor on it, so there's nothing left + // for HostAccessor to reconcile separately. +} + /** * Base middleware accessor for components that require msvcrt.dll in the target process. */ diff --git a/packages/xffi/src/win/defines.ts b/packages/xffi/src/win/defines.ts index 75dba0f..4e9af4f 100644 --- a/packages/xffi/src/win/defines.ts +++ b/packages/xffi/src/win/defines.ts @@ -542,3 +542,16 @@ export const CreateRestrictedTokenFlags = cdefines({ export type CreateRestrictedTokenFlags = CDefineValueType< typeof CreateRestrictedTokenFlags >; + +// 28. StartupInfoFlags (STARTUPINFO.dwFlags) +export const StartupInfoFlags = cdefines({ + USESHOWWINDOW: 0x00000001, +}); +export type StartupInfoFlags = CDefineValueType; + +// 29. ShowWindowCommand (STARTUPINFO.wShowWindow / ShowWindow's nCmdShow) +export const ShowWindowCommand = cdefines({ + SW_HIDE: 0, + SW_SHOWNORMAL: 1, +}); +export type ShowWindowCommand = CDefineValueType; diff --git a/tests/accessors/create-accessor.test.ts b/tests/accessors/create-accessor.test.ts new file mode 100644 index 0000000..fcabd91 --- /dev/null +++ b/tests/accessors/create-accessor.test.ts @@ -0,0 +1,343 @@ +import { expect, test, describe } from 'bun:test'; +import { + Kernel32Impl, + resolveAddress, + Thread, + IndirectNThreadHostAccessor, + RemoteMemoryAccessor, + createAccessor, + createAccessorWithoutInit, + createAccessorOptions, + isInittableAccessor, + struct, + type HostAccessor, + type NThreadOptions, +} from 'exoproc'; +import { getGlobalDummyProcess } from 'exoproc-dummy'; + +describe('createAccessorWithoutInit', () => { + test('returns synchronously (not a Promise), without initializing', () => { + const proc = getGlobalDummyProcess(); + const thread = Thread.getThreads(proc.pid)[0]; + if (!thread) throw new Error('No thread found in the spawned process'); + + // idType: 'thread' explicitly -- see below for the default + // (idType: 'processAllThreadIds'). + const memory = createAccessorWithoutInit(thread.tid, { + idType: 'thread', + }); + expect(memory).not.toBeInstanceOf(Promise); + expect(memory).toBeInstanceOf(IndirectNThreadHostAccessor); + memory.close(); + }); + + test('idType: "processAllThreadIds" (the default) also returns synchronously, without racing yet', () => { + const proc = getGlobalDummyProcess(); + // A genuine IndirectNThreadHostAccessor, same as every other idType -- + // NThreadRaceAccessor (its internal `NThread` stand-in) never surfaces + // to callers, see its doc comment. Its own constructor builds every + // candidate NThread synchronously; racing them is what init() defers. + const memory = createAccessorWithoutInit(proc.pid); + expect(memory).not.toBeInstanceOf(Promise); + expect(memory).toBeInstanceOf(IndirectNThreadHostAccessor); + memory.close(); + }); + + test('idType: "processAllThreadIds" explicitly behaves the same as the default', () => { + const proc = getGlobalDummyProcess(); + const memory = createAccessorWithoutInit(proc.pid, { + idType: 'processAllThreadIds', + }); + expect(memory).not.toBeInstanceOf(Promise); + expect(memory).toBeInstanceOf(IndirectNThreadHostAccessor); + memory.close(); + }); + + test('idType: "processAllThreadIds" throws synchronously when the process has no threads', () => { + const bogusPid = 999999; + expect(() => createAccessorWithoutInit(bogusPid)).toThrow( + /no threads to redirect/, + ); + }); +}); + +describe('createAccessor', () => { + test('is async and awaits init before resolving', () => { + const proc = getGlobalDummyProcess(); + + const result = createAccessor(proc.pid); + expect(result).toBeInstanceOf(Promise); + return result.then((memory) => memory.deinit()); + }, 30000); + + test('defaults to a process id and races all its threads', async () => { + const proc = getGlobalDummyProcess(); + + const memory = await createAccessor(proc.pid); + expect(memory).toBeInstanceOf(IndirectNThreadHostAccessor); + + try { + const remoteTid = await memory.call(Kernel32Impl.GetCurrentThreadId); + expect(Thread.getThreads(proc.pid).some((t) => t.tid === remoteTid)).toBe( + true, + ); + + const addr = await memory.alloc(64); + expect(Number(resolveAddress(addr))).toBeGreaterThan(0); + + const data = Buffer.from('createAccessor default chain!'); + await memory.write(addr, data); + const back = await memory.read(addr, data.byteLength); + expect(back.toString()).toBe(data.toString()); + await memory.free(addr); + } finally { + await memory.deinit(); + } + }, 30000); + + test('idType: "thread" names one specific thread directly', async () => { + const proc = getGlobalDummyProcess(); + const thread = Thread.getThreads(proc.pid)[0]; + if (!thread) throw new Error('No thread found in the spawned process'); + + const memory = await createAccessor(thread.tid, { idType: 'thread' }); + try { + const remoteTid = await memory.call(Kernel32Impl.GetCurrentThreadId); + expect(Number(remoteTid)).toBe(thread.tid); + } finally { + await memory.deinit(); + } + }, 30000); + + test('idType: "process" auto-picks the process\'s first thread', async () => { + const proc = getGlobalDummyProcess(); + const memory = await createAccessor(proc.pid, { idType: 'process' }); + try { + const remoteTid = await memory.call(Kernel32Impl.GetCurrentThreadId); + expect(Thread.getThreads(proc.pid).some((t) => t.tid === remoteTid)).toBe( + true, + ); + } finally { + await memory.deinit(); + } + }, 30000); + + test('idType: "processAllThreadIds" races every thread and returns whichever one initializes', async () => { + const proc = getGlobalDummyProcess(); + const memory = await createAccessor(proc.pid, { + idType: 'processAllThreadIds', + hostOptions: { timeoutMs: 20000 }, + }); + try { + // The winner is a real, already-initialized accessor on one of the + // process's own threads -- same observable contract as idType: 'process'. + const remoteTid = await memory.call(Kernel32Impl.GetCurrentThreadId); + expect(Thread.getThreads(proc.pid).some((t) => t.tid === remoteTid)).toBe( + true, + ); + + const addr = await memory.alloc(64); + const data = Buffer.from('processAllThreadIds race winner!'); + await memory.write(addr, data); + expect((await memory.read(addr, data.byteLength)).toString()).toBe( + data.toString(), + ); + await memory.free(addr); + } finally { + await memory.deinit(); + } + }, 30000); + + test('idType: "processAllThreadIds" throws when the process has no threads', async () => { + const bogusPid = 999999; + await expect( + createAccessor(bogusPid, { idType: 'processAllThreadIds' }), + ).rejects.toThrow(/no threads to redirect/); + }); + + test('options.backend is returned directly, without touching id/idType', () => { + const sentinel = {} as unknown as HostAccessor; + + // A bogus id would normally throw during resolution (see the two error + // tests below) -- passing `backend` must skip that resolution entirely. + // Sync createAccessorWithoutInit, since the sentinel isn't a real + // inittable accessor -- no init to await here. + const memory = createAccessorWithoutInit(0, { backend: sentinel }); + + expect(memory).toBe(sentinel); + }); + + test('throws when no thread has the given id', () => { + const bogusThreadId = 999999999; + // createAccessorWithoutInit -- pure id-resolution failure, thrown + // synchronously before any accessor is built. idType: 'thread' explicit + // so the id is looked up as a thread id, not a pid. + expect(() => + createAccessorWithoutInit(bogusThreadId, { idType: 'thread' }), + ).toThrow(/no thread with id/); + }); + + test('throws when the given process has no threads to redirect', () => { + // A pid that (almost certainly) does not correspond to a live process -- + // Thread.getThreads filters a Toolhelp32 snapshot by pid, so an unmatched + // pid yields an empty array rather than an error. + const bogusPid = 999999; + expect(() => + createAccessorWithoutInit(bogusPid, { idType: 'process' }), + ).toThrow(/no threads to redirect/); + }); + + test('sharedMemory: true backs plain allocations with NShm', async () => { + const proc = getGlobalDummyProcess(); + const thread = Thread.getThreads(proc.pid)[0]; + if (!thread) throw new Error('No thread found in the spawned process'); + + // sharedMemory: true splices NShm into the resolved accessor's own + // backend chain and returns that same accessor (see + // createAccessorWithoutInit's doc comment) -- `memory` here really is + // the IndirectNThreadHostAccessor idType: 'thread' would have returned + // on its own, so its own deinit()/call() work directly, no separate + // handle needed just for cleanup. + const memory = await createAccessor(thread.tid, { + idType: 'thread', + sharedMemory: true, + }); + expect(memory).toBeInstanceOf(IndirectNThreadHostAccessor); + + const addr = await memory.alloc(4096); + // Independent raw ReadProcessMemory check (not another NThread hijack of + // the same already-hijacked thread) -- proves the write really landed in + // the target's shared section, not just NShm's own local view. + const raw = new RemoteMemoryAccessor(proc.pid); + try { + const marker = Buffer.from('createAccessor shared memory!\0'); + await memory.write(addr, marker); + + const seenInTarget = raw.readSync(addr, marker.byteLength); + expect(seenInTarget.toString()).toBe(marker.toString()); + } finally { + raw.close(); + await memory.call(Kernel32Impl.UnmapViewOfFile, addr); + await memory.free(addr); + await memory.deinit(); + } + }, 30000); + + test('sharedMemory: true also works with the default idType (processAllThreadIds)', async () => { + const proc = getGlobalDummyProcess(); + + // No idType -- resolveBaseAccessor still returns a genuine + // IndirectNThreadHostAccessor synchronously (NThreadRaceAccessor is + // nested inside it, standing in for `.nthread` until init() resolves the + // race -- see its doc comment), so the sharedMemory splice in + // createAccessorWithoutInit lands on the *same* object whether racing is + // involved or not, and nothing about init()/onInit() ever reassigns + // `IndirectNThreadHostAccessor`'s own `backend` afterward to disturb it. + const memory = await createAccessor(proc.pid, { sharedMemory: true }); + expect(memory).toBeInstanceOf(IndirectNThreadHostAccessor); + + const addr = await memory.alloc(4096); + const raw = new RemoteMemoryAccessor(proc.pid); + try { + const marker = Buffer.from('processAllThreadIds shared memory!\0'); + await memory.write(addr, marker); + + const seenInTarget = raw.readSync(addr, marker.byteLength); + expect(seenInTarget.toString()).toBe(marker.toString()); + } finally { + raw.close(); + await memory.call(Kernel32Impl.UnmapViewOfFile, addr); + await memory.free(addr); + await memory.deinit(); + } + }, 30000); +}); + +describe('createAccessorOptions', () => { + test('defaults to the gentle (level 1) preset, with shared memory off', () => { + expect(createAccessorOptions()).toEqual(createAccessorOptions(1)); + expect(createAccessorOptions(1)).toEqual({ + hostOptions: { timeoutMs: 20000, pollIntervalMs: 2 }, + sharedMemory: false, + }); + }); + + test('level 2 is more aggressive (shorter timeout) and turns shared memory on', () => { + const gentle = createAccessorOptions(1); + const balanced = createAccessorOptions(2); + + // hostOptions is untyped (Record) since it's forwarded + // to whatever `host` class is in play -- cast back to the default + // host's NThreadOptions shape to inspect it here. + const balancedHostOptions = balanced.hostOptions as NThreadOptions; + const gentleHostOptions = gentle.hostOptions as NThreadOptions; + expect(balancedHostOptions.timeoutMs!).toBeLessThan( + gentleHostOptions.timeoutMs!, + ); + expect(gentle.sharedMemory).toBe(false); + expect(balanced.sharedMemory).toBe(true); + }); + + test('the returned template drives a real createAccessor call', async () => { + const proc = getGlobalDummyProcess(); + const options = createAccessorOptions(1); + options.idType = 'process'; + + const memory = await createAccessor(proc.pid, options); + try { + const remoteTid = await memory.call(Kernel32Impl.GetCurrentThreadId); + expect(Thread.getThreads(proc.pid).some((t) => t.tid === remoteTid)).toBe( + true, + ); + } finally { + if (isInittableAccessor(memory)) await memory.deinit(); + } + }, 30000); +}); + +describe('createAccessor + SyncStruct (level 2 / sharedMemory: true)', () => { + test('struct field reads/writes are synchronous (no await) and land cross-process', async () => { + const proc = getGlobalDummyProcess(); + + // createAccessor() itself is still async (it awaits init()/the thread + // race up front) -- idType defaults to 'processAllThreadIds', which + // races every thread of proc.pid and lands the hijack on whichever one + // wins, no manual thread picked here. Everything after this point -- + // allocating the struct and reading/writing its fields -- is plain + // synchronous code, no await anywhere below. sharedMemory: true (level + // 2's preset) means Player.allocSync() below is backed by NShm: the + // struct's backing memory is mapped into this process too, so every + // field access after the initial allocation skips the remote round trip + // entirely. + const memory = await createAccessor(proc.pid, createAccessorOptions(2)); + expect(memory).toBeInstanceOf(IndirectNThreadHostAccessor); + + const Player = struct({ health: 'i32', mana: 'i32' }); + const player = Player.allocSync(memory); + + try { + player.health = 100; + player.mana = 50; + expect(player.health).toBe(100); + expect(player.mana).toBe(50); + + player.health -= 35; + expect(player.health).toBe(65); + + // Independent raw ReadProcessMemory check (not another NThread hijack + // of the same thread) -- proves the synchronous writes above really + // landed in the target process's memory, not just a local mirror. + const raw = new RemoteMemoryAccessor(proc.pid); + try { + expect(raw.readSync(player.address, 4).readInt32LE(0)).toBe(65); + expect(raw.readSync(player.address, 4, 4).readInt32LE(0)).toBe(50); + } finally { + raw.close(); + } + } finally { + await memory.call(Kernel32Impl.UnmapViewOfFile, player.address); + await memory.free(player.address); + await memory.deinit(); + } + }, 30000); +}); diff --git a/tests/minhook/minhook-indirect-nthread.test.ts b/tests/minhook/minhook-indirect-nthread.test.ts index 42acf14..b311af1 100644 --- a/tests/minhook/minhook-indirect-nthread.test.ts +++ b/tests/minhook/minhook-indirect-nthread.test.ts @@ -5,7 +5,7 @@ import { createCFunction, RemoteCallableMemoryAccessor, Kernel32Impl, - IndirectNThreadHostAccessor, + createAccessor, MinHook, } from 'exoproc'; import { getGlobalDummyProcess } from 'exoproc-dummy'; @@ -25,11 +25,8 @@ describe('MinHook over IndirectNThreadHostAccessor (cross-process, thread-hijack const proc = getGlobalDummyProcess(); test('hooks a function in another process and its detours run when invoked via the hijacked thread', async () => { - const thread = Native.Thread.getThreads(proc.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - - const memory = new IndirectNThreadHostAccessor(proc.pid, thread.tid, { - timeoutMs: 20000, + const memory = await createAccessor(proc.pid, { + hostOptions: { timeoutMs: 20000 }, }); const minhook = new MinHook(proc.pid); // Independent view: raw ReadProcessMemory, nothing in common with the @@ -108,7 +105,10 @@ describe('MinHook over IndirectNThreadHostAccessor (cross-process, thread-hijack // thread and returns its tid. (Proves HookDetour accepts a plain // CFunction, resolved by address with no CMachineCode handling.) await hook.enable(Kernel32Impl.GetCurrentThreadId); - expect(Number(await memory.call(target, 10))).toBe(thread.tid); + const detourTid = Number(await memory.call(target, 10)); + expect( + Native.Thread.getThreads(proc.pid).some((t) => t.tid === detourTid), + ).toBe(true); // disable() restores the original prologue -> unhooked behaviour returns. await hook.disable(); diff --git a/tests/minhook/minhook.test.ts b/tests/minhook/minhook.test.ts index 04f1740..62f1f2c 100644 --- a/tests/minhook/minhook.test.ts +++ b/tests/minhook/minhook.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test } from 'bun:test'; -import * as Native from 'exoproc'; import { cmachinecode, createCFunction, - IndirectNThreadHostAccessor, + createAccessor, MinHook, } from 'exoproc'; import { getGlobalDummyProcess } from 'exoproc-dummy'; @@ -20,11 +19,8 @@ describe('MinHook end-to-end lifecycle (real compiled target + real compiled det const proc = getGlobalDummyProcess(); test('create() builds a trampoline without touching the target; enable() installs the JMP and the detour actually runs', async () => { - const thread = Native.Thread.getThreads(proc.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - - const memory = new IndirectNThreadHostAccessor(proc.pid, thread.tid, { - timeoutMs: 20000, + const memory = await createAccessor(proc.pid, { + hostOptions: { timeoutMs: 20000 }, }); const minhook = new MinHook(proc.pid); diff --git a/tests/nhook/nhook.test.ts b/tests/nhook/nhook.test.ts index bf8d967..bff6268 100644 --- a/tests/nhook/nhook.test.ts +++ b/tests/nhook/nhook.test.ts @@ -8,7 +8,8 @@ import { resolveAddress, CapstoneX86, type Instruction, - IndirectNThreadHostAccessor, + createAccessor, + type HostAccessor, NHook, NHookInstance, type NHookPoolResult, @@ -27,7 +28,7 @@ import { getGlobalDummyProcess } from 'exoproc-dummy'; // needed: an existing thread in the spawned process is hijacked directly. describe('NHook instruction simulation', () => { const proc = getGlobalDummyProcess(); - let memory: IndirectNThreadHostAccessor; + let memory: HostAccessor; let nhook: NHook; const capstone = new CapstoneX86(); @@ -35,11 +36,8 @@ describe('NHook instruction simulation', () => { let scratchMid: bigint; beforeAll(async () => { - const thread = Native.Thread.getThreads(proc.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - - memory = new IndirectNThreadHostAccessor(proc.pid, thread.tid, { - timeoutMs: 20000, + memory = await createAccessor(proc.pid, { + hostOptions: { timeoutMs: 20000 }, }); nhook = new NHook(proc.pid); diff --git a/tests/nshm/nshm.test.ts b/tests/nshm/nshm.test.ts index 040662c..e9a5a3d 100644 --- a/tests/nshm/nshm.test.ts +++ b/tests/nshm/nshm.test.ts @@ -1,9 +1,6 @@ import { expect, test, describe } from 'bun:test'; -import * as Native from 'bun-winapi'; import { Kernel32Impl } from 'bun-xffi'; -import { HostAccessor } from 'exoproc-accessors'; -import { IndirectNThreadHostAccessor } from 'bun-nthread'; -import { NShm } from 'bun-nshm'; +import { createAccessor, createAccessorWithoutInit } from 'exoproc-accessors'; import { getGlobalDummyProcess } from 'exoproc-dummy'; // Proves the full handle-relay flow: this (Bun) process never OpenProcess's @@ -29,14 +26,15 @@ import { getGlobalDummyProcess } from 'exoproc-dummy'; describe('nshm > NShm (handle relay via a single shared dummy process)', () => { test('shares a genuinely usable mapping/view with both the target and this process', async () => { const target = getGlobalDummyProcess(); - const thread = Native.Thread.getThreads(target.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - const memory = new IndirectNThreadHostAccessor(target.pid, thread.tid, { - timeoutMs: 20000, + const memory = createAccessorWithoutInit(target.pid, { + idType: 'process', + hostOptions: { timeoutMs: 20000 }, + }); + const shm = await createAccessor(target.pid, { + backend: memory, + sharedMemory: true, }); - const host = new HostAccessor(memory); - const shm = new NShm(memory, host); const addr = await shm.alloc(4096); @@ -70,14 +68,15 @@ describe('nshm > NShm (handle relay via a single shared dummy process)', () => { test('supports multiple independent shared memory regions on the same target', async () => { const target = getGlobalDummyProcess(); - const thread = Native.Thread.getThreads(target.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - const memory = new IndirectNThreadHostAccessor(target.pid, thread.tid, { - timeoutMs: 20000, + const memory = createAccessorWithoutInit(target.pid, { + idType: 'process', + hostOptions: { timeoutMs: 20000 }, + }); + const shm = await createAccessor(target.pid, { + backend: memory, + sharedMemory: true, }); - const host = new HostAccessor(memory); - const shm = new NShm(memory, host); const addr1 = await shm.alloc(4096); const addr2 = await shm.alloc(4096); diff --git a/tests/nthread/call-redirector-nthread.test.ts b/tests/nthread/call-redirector-nthread.test.ts index f2fd357..76cdeb2 100644 --- a/tests/nthread/call-redirector-nthread.test.ts +++ b/tests/nthread/call-redirector-nthread.test.ts @@ -1,7 +1,6 @@ import { expect, test, describe } from 'bun:test'; -import * as Native from 'bun-winapi'; import { MemoryProtection } from 'bun-xffi'; -import { IndirectNThreadHostAccessor } from 'bun-nthread'; +import { createAccessor } from 'exoproc-accessors'; import { getGlobalDummyProcess } from 'exoproc-dummy'; // Moved from tests/xffi/call-redirector.test.ts -- CallRedirectorAccessor/ @@ -19,11 +18,9 @@ import { getGlobalDummyProcess } from 'exoproc-dummy'; describe('nthread > IndirectCallRedirectorAccessor.protect() over IndirectNThreadHostAccessor', () => { test('mocks protect() for malloc blocks (throw on non-READWRITE, no-op on READWRITE) and calls real VirtualProtect otherwise', async () => { const tp = getGlobalDummyProcess(); - const thread = Native.Thread.getThreads(tp.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - const accessor = new IndirectNThreadHostAccessor(tp.pid, thread.tid, { - timeoutMs: 20000, + const accessor = await createAccessor(tp.pid, { + hostOptions: { timeoutMs: 20000 }, }); try { diff --git a/tests/nthread/cmachinecode-remote.test.ts b/tests/nthread/cmachinecode-remote.test.ts index c5cd902..2f69e9f 100644 --- a/tests/nthread/cmachinecode-remote.test.ts +++ b/tests/nthread/cmachinecode-remote.test.ts @@ -1,7 +1,6 @@ import { expect, test, describe } from 'bun:test'; -import * as Native from 'bun-winapi'; import { cmachinecode, CType, createCFunction } from 'bun-xffi'; -import { IndirectNThreadHostAccessor } from 'bun-nthread'; +import { createAccessor } from 'exoproc-accessors'; import { getGlobalDummyProcess } from 'exoproc-dummy'; // Moved from tests/xffi/cmachinecode.test.ts -- the injected shell itself calls @@ -77,11 +76,9 @@ describe('nthread > cmachinecode remote execution', () => { }); const tp = getGlobalDummyProcess(); - const thread = Native.Thread.getThreads(tp.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - const accessor = new IndirectNThreadHostAccessor(tp.pid, thread.tid, { - timeoutMs: 20000, + const accessor = await createAccessor(tp.pid, { + hostOptions: { timeoutMs: 20000 }, }); try { diff --git a/tests/nthread/indirect-nthread-host-accessor.test.ts b/tests/nthread/indirect-nthread-host-accessor.test.ts index 48dd87f..d20589b 100644 --- a/tests/nthread/indirect-nthread-host-accessor.test.ts +++ b/tests/nthread/indirect-nthread-host-accessor.test.ts @@ -6,7 +6,7 @@ import { MemoryState, resolveAddress, } from 'bun-xffi'; -import { IndirectNThreadHostAccessor } from 'bun-nthread'; +import { createAccessor } from 'exoproc-accessors'; import { getGlobalDummyProcess } from 'exoproc-dummy'; // Pre-wired form of the manual chain in nthread.test.ts: a full indirect host @@ -16,18 +16,19 @@ import { getGlobalDummyProcess } from 'exoproc-dummy'; describe('IndirectNThreadHostAccessor (indirect chain over NThread hijacking)', () => { test('runs remote calls on the hijacked thread and does indirect alloc/write/read', async () => { const proc = getGlobalDummyProcess(); - const thread = Native.Thread.getThreads(proc.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - const memory = new IndirectNThreadHostAccessor(proc.pid, thread.tid, { - timeoutMs: 20000, + const memory = await createAccessor(proc.pid, { + hostOptions: { timeoutMs: 20000 }, }); try { // A call executes *on the hijacked thread itself*: GetCurrentThreadId - // returns exactly the thread we parked at the jmp$ stub. + // returns exactly the (winning) thread createAccessor parked at the + // jmp$ stub. const remoteTid = await memory.call(Kernel32Impl.GetCurrentThreadId); - expect(Number(remoteTid)).toBe(thread.tid); + expect( + Native.Thread.getThreads(proc.pid).some((t) => t.tid === remoteTid), + ).toBe(true); // Indirect alloc/write/read round-trip inside the target process. const addr = await memory.alloc(64); @@ -50,11 +51,9 @@ describe('IndirectNThreadHostAccessor (indirect chain over NThread hijacking)', // space, so proving it works here is what makes minhook-over-indirect viable. test('allocNear finds executable space near an anchor entirely via the hijacked thread', async () => { const proc = getGlobalDummyProcess(); - const thread = Native.Thread.getThreads(proc.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - const memory = new IndirectNThreadHostAccessor(proc.pid, thread.tid, { - timeoutMs: 20000, + const memory = await createAccessor(proc.pid, { + hostOptions: { timeoutMs: 20000 }, }); try { diff --git a/tests/nthread/module-helpers-nthread.test.ts b/tests/nthread/module-helpers-nthread.test.ts index b556d93..90889b2 100644 --- a/tests/nthread/module-helpers-nthread.test.ts +++ b/tests/nthread/module-helpers-nthread.test.ts @@ -1,11 +1,10 @@ import { expect, test, describe } from 'bun:test'; -import * as Native from 'bun-winapi'; import { isModuleLoadedInProcess, verifyCoreModules, Kernel32Impl, } from 'bun-xffi'; -import { IndirectNThreadHostAccessor } from 'bun-nthread'; +import { createAccessor } from 'exoproc-accessors'; import { getGlobalDummyProcess } from 'exoproc-dummy'; // Moved from tests/xffi/module-helpers.test.ts -- GetModuleHandleExA(FROM_ADDRESS) @@ -17,11 +16,9 @@ import { getGlobalDummyProcess } from 'exoproc-dummy'; describe('nthread > Module Loading Helpers', () => { test('should check loaded modules in the current process', async () => { const tp = getGlobalDummyProcess(); - const thread = Native.Thread.getThreads(tp.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - const accessor = new IndirectNThreadHostAccessor(tp.pid, thread.tid, { - timeoutMs: 20000, + const accessor = await createAccessor(tp.pid, { + hostOptions: { timeoutMs: 20000 }, }); try { diff --git a/tests/nthread/nthread.test.ts b/tests/nthread/nthread.test.ts index 4422d37..c9fa8e1 100644 --- a/tests/nthread/nthread.test.ts +++ b/tests/nthread/nthread.test.ts @@ -6,7 +6,10 @@ import { CrtImpl, cmachinecode, } from 'bun-xffi'; -import { RedirectorHostAccessor } from 'exoproc-accessors'; +import { + RedirectorHostAccessor, + IndirectNThreadHostAccessor, +} from 'exoproc-accessors'; // Compiled once at module load; reused across tests. const sum8f = cmachinecode({ @@ -16,7 +19,6 @@ const sum8f = cmachinecode({ }); import { NThread, - IndirectNThreadHostAccessor, getRandomSpinStub, getRandomPushretStub, getRandomJumpStub, diff --git a/tests/nthread/process-cache-accessor-nthread.test.ts b/tests/nthread/process-cache-accessor-nthread.test.ts index 50e8bd1..bfebb48 100644 --- a/tests/nthread/process-cache-accessor-nthread.test.ts +++ b/tests/nthread/process-cache-accessor-nthread.test.ts @@ -1,7 +1,9 @@ import { expect, test, describe } from 'bun:test'; -import * as Native from 'bun-winapi'; -import { ProcessCacheAccessor, HostAccessor } from 'exoproc-accessors'; -import { IndirectNThreadHostAccessor } from 'bun-nthread'; +import { + ProcessCacheAccessor, + HostAccessor, + createAccessor, +} from 'exoproc-accessors'; import { getGlobalDummyProcess } from 'exoproc-dummy'; // Moved from tests/xffi/process-cache-accessor.test.ts -- ProcessCacheAccessor. @@ -20,14 +22,10 @@ import { getGlobalDummyProcess } from 'exoproc-dummy'; describe('nthread > ProcessCacheAccessor', () => { test('should resolve metadata and cache status using a real target process', async () => { const tp = getGlobalDummyProcess(); - const thread = Native.Thread.getThreads(tp.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - - const nthreadAccessor = new IndirectNThreadHostAccessor( - tp.pid, - thread.tid, - { timeoutMs: 20000 }, - ); + + const nthreadAccessor = await createAccessor(tp.pid, { + hostOptions: { timeoutMs: 20000 }, + }); try { const host = new HostAccessor(nthreadAccessor); diff --git a/tests/nthread/scanner-indirect-nthread.test.ts b/tests/nthread/scanner-indirect-nthread.test.ts index 9439adb..50a03d8 100644 --- a/tests/nthread/scanner-indirect-nthread.test.ts +++ b/tests/nthread/scanner-indirect-nthread.test.ts @@ -1,7 +1,6 @@ import { expect, test, describe } from 'bun:test'; -import * as Native from 'bun-winapi'; import { resolveAddress } from 'bun-xffi'; -import { IndirectNThreadHostAccessor } from 'bun-nthread'; +import { createAccessor } from 'exoproc-accessors'; import { getGlobalDummyProcess } from 'exoproc-dummy'; // Moved from tests/xffi/scanner.test.ts ("should support remote process JIT @@ -15,11 +14,9 @@ describe('nthread > Scanner over IndirectNThreadHostAccessor', () => { if (process.platform !== 'win32') return; const tp = getGlobalDummyProcess(); - const thread = Native.Thread.getThreads(tp.pid)[0]; - if (!thread) throw new Error('No thread found in the spawned process'); - const accessor = new IndirectNThreadHostAccessor(tp.pid, thread.tid, { - timeoutMs: 20000, + const accessor = await createAccessor(tp.pid, { + hostOptions: { timeoutMs: 20000 }, }); try { diff --git a/tsconfig.json b/tsconfig.json index 3e55559..1e382be 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -56,14 +56,14 @@ "references": [ { "path": "./packages/utils" }, { "path": "./packages/xffi" }, - { "path": "./packages/accessors" }, + { "path": "./packages/winapi" }, { "path": "./packages/dummy" }, { "path": "./packages/capstone" }, - { "path": "./packages/winapi" }, { "path": "./packages/nthread" }, + { "path": "./packages/nshm" }, + { "path": "./packages/accessors" }, { "path": "./packages/nhook" }, { "path": "./packages/minhook" }, - { "path": "./packages/nshm" }, { "path": "./packages/exoproc" } ] }