exoproc-accessors 0.1.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,8 @@ import { randomUUID } from 'crypto';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import * as os from 'os';
5
- import { NativeMemory, normalizeType, resolveAddress, alignUp, computeNearAllocRange, freeRegionCandidate, AllocNearRangeError, Kernel32Impl, MsvcrtImpl, MsvcrtLibrary, MemoryBasicInformation, PROCESSENTRY32W_SIZE, MemoryState, MemoryProtection, MemoryFreeType, ToolhelpSnapshotFlag, ProcessAccess, DEFAULT_MACHINECODE_ALIGNMENT, memmem, memmem1, memmem2, memmem4, memmem8, memmemWithoutBuffer, Pattern, Scanner, verifyCoreModules, verifyCoreModulesSync, isModuleLoadedInProcess, isModuleLoadedInProcessSync, MiddlewareAccessor, InittableMiddlewareAccessor, isMiddlewareAccessor, } from 'bun-xffi';
5
+ import { NativeMemory, normalizeType, resolveAddress, alignUp, computeNearAllocRange, freeRegionCandidate, AllocNearRangeError, Kernel32Impl, MsvcrtImpl, MsvcrtLibrary, MemoryBasicInformation, PROCESSENTRY32W_SIZE, MemoryState, MemoryProtection, MemoryFreeType, ToolhelpSnapshotFlag, ProcessAccess, DEFAULT_MACHINECODE_ALIGNMENT, memmem, memmem1, memmem2, memmem4, memmem8, memmemWithoutBuffer, Pattern, Scanner, verifyCoreModules, verifyCoreModulesSync, isModuleLoadedInProcess, isModuleLoadedInProcessSync, MiddlewareAccessor, InittableMiddlewareAccessor, isInittableAccessor, HostAccessor, } from 'bun-xffi';
6
+ export { HostAccessor };
6
7
  export class MsvcrtDependentMiddlewareAccessor extends InittableMiddlewareAccessor {
7
8
  async onInit() {
8
9
  if (this.isLocal)
@@ -23,34 +24,6 @@ export class MsvcrtDependentMiddlewareAccessor extends InittableMiddlewareAccess
23
24
  }
24
25
  }
25
26
  }
26
- /**
27
- * HostAccessor is a base class that automatically initializes all nested InittableMiddlewareAccessors
28
- * in the backend decorator chain.
29
- */
30
- export class HostAccessor extends InittableMiddlewareAccessor {
31
- get processId() {
32
- return this._processId;
33
- }
34
- constructor(backend, root) {
35
- super(backend, root ?? null);
36
- if (!root) {
37
- this.root = this;
38
- }
39
- let b = backend;
40
- while (isMiddlewareAccessor(b)) {
41
- b = b.backend;
42
- }
43
- if (b) {
44
- this._processId = b.processId;
45
- }
46
- }
47
- async onInit() {
48
- // No-op. Chain initialization is automatically propagated by init().
49
- }
50
- onInitSync() {
51
- // No-op. Chain initialization is automatically propagated by initSync().
52
- }
53
- }
54
27
  /**
55
28
  * A HostAccessor that forwards all operations to a dynamically changeable target HostAccessor.
56
29
  * This is useful for resolving circular dependencies in middleware chains where the root
@@ -77,7 +50,15 @@ export class RedirectorHostAccessor extends HostAccessor {
77
50
  _target;
78
51
  constructor(processId, root) {
79
52
  super(new ThrowingMemoryAccessor(processId), root);
80
- this._target = new ThrowingHostAccessor(processId, this.root);
53
+ // Deliberately self-rooted (no `root` arg) rather than `this.root`: this
54
+ // placeholder is never a real candidate, but constructing it with the
55
+ // outer `root` would still run through `MiddlewareAccessor`'s
56
+ // constructor -- which unconditionally calls `root.registerChild(this)`
57
+ // -- and register the placeholder itself as a racer on a
58
+ // `RacingHostAccessor` root. Since it always throws and never does real
59
+ // work, it would "win" the race instantly, before any genuine candidate
60
+ // could land.
61
+ this._target = new ThrowingHostAccessor(processId);
81
62
  }
82
63
  get target() {
83
64
  return this._target;
@@ -232,10 +213,126 @@ export class RedirectorHostAccessor extends HostAccessor {
232
213
  async onInit() {
233
214
  // No-op. The target is initialized separately.
234
215
  }
216
+ /**
217
+ * Mirrors the `close()` override above for the async lifecycle: every op
218
+ * (read/write/call/...) routes to `target` once it's no longer `this`, so
219
+ * `deinit()` must too. Without this, the inherited `deinitNext()` (from
220
+ * `InittableMiddlewareAccessor`) walks `this.backend` -- which for a
221
+ * subclass like `NThreadRaceAccessor` stays pointed at its own
222
+ * placeholder `ThrowingMemoryAccessor` forever (only `target` ever gets
223
+ * reassigned to the winner, per `RedirectorHostAccessor`'s whole design;
224
+ * see its class doc comment) -- so the real accessor `target` resolved to
225
+ * would never actually get deinited, leaving its underlying resource
226
+ * (e.g. a hijacked thread) permanently stuck.
227
+ *
228
+ * Deliberately excludes `t === this.root`: `BootstrapHostAccessor.onInit()`
229
+ * itself resolves `target` to `this.root` once its own backend has landed
230
+ * -- that's a hand-off back to whatever *contains* this accessor (e.g. a
231
+ * per-candidate `BootstrapHostAccessor` inside `NThreadRaceAccessor`
232
+ * resolves to the race accessor itself), not a distinct resource this
233
+ * accessor now owns. Cascading `deinit()` there would tear down the
234
+ * container (and everything else riding on it, e.g. the actual race
235
+ * winner) from a single losing candidate's own cleanup.
236
+ */
237
+ async onDeinit() {
238
+ const t = this.getTarget();
239
+ if (t !== this && t !== this.root && isInittableAccessor(t)) {
240
+ await t.deinit();
241
+ }
242
+ }
235
243
  onInitSync() {
236
244
  // No-op. The target is initialized separately.
237
245
  }
238
246
  }
247
+ /**
248
+ * A minimal `RedirectorHostAccessor` mixin meant to be *extended*, not used
249
+ * directly -- its only job is giving a subclass (e.g. `NThreadRaceAccessor`)
250
+ * two things for free: automatic candidate collection, and a `race()` helper
251
+ * to actually run the race once the subclass's own `onInit()` decides it's
252
+ * time. `target` (inherited) is left for the subclass to set to the winner
253
+ * however makes sense for it -- this class never touches it itself.
254
+ *
255
+ * Candidates are never added explicitly -- every `MiddlewareAccessor`'s
256
+ * constructor calls `root.registerChild(this)` (a no-op on every other
257
+ * `IHostAccessor`; see its doc comment), so simply constructing a candidate
258
+ * with a `RacingHostAccessor` (sub)instance as its `root` is enough to enter
259
+ * it into the race.
260
+ *
261
+ * Deliberately knows nothing about *why* a caller is racing candidates -- no
262
+ * `NThread`, no `AbortController`, no timeout, no `AsyncLocalStorage`; a
263
+ * caller that needs to cancel the losers does so itself, by extending this
264
+ * class and overriding `releaseLoser`, and a caller whose candidates need
265
+ * per-candidate context threaded through their own `init()` (e.g.
266
+ * `NThreadRaceAccessor`, whose candidates all share one root and need
267
+ * `root.call()` to resolve back to *this specific* candidate while racing)
268
+ * does so by overriding `startRacer` -- kept as a single, narrow extension
269
+ * point instead of baking any particular mechanism (like
270
+ * `AsyncLocalStorage`) into this generic base.
271
+ */
272
+ export class RacingHostAccessor extends RedirectorHostAccessor {
273
+ racers = [];
274
+ registerChild(child) {
275
+ // Can fire before our own field initializers run: RedirectorHostAccessor's
276
+ // constructor builds a placeholder `new ThrowingHostAccessor(processId,
277
+ // this.root)`, and `this.root` is this very instance (self-rooted, still
278
+ // mid-construction) when nothing else was passed as `root` -- `this.racers`
279
+ // isn't assigned yet at that point. Harmless to skip: real candidates
280
+ // always register later, from a subclass's own constructor body, which
281
+ // only runs after every `super()` call (and therefore every field
282
+ // initializer) has completed.
283
+ if (!this.racers || !isInittableAccessor(child))
284
+ return;
285
+ this.racers.push(child);
286
+ }
287
+ /**
288
+ * Launches one racer's `init()`. Overridable so a subclass can wrap the
289
+ * call with its own per-candidate context (see `NThreadRaceAccessor`,
290
+ * which threads an `AsyncLocalStorage` context through this so its shared
291
+ * root's `call()` can resolve back to whichever candidate is currently
292
+ * bootstrapping) without this base class needing to know why.
293
+ */
294
+ startRacer(racer) {
295
+ return racer.init();
296
+ }
297
+ /**
298
+ * Races every registered racer's `init()` and returns whichever settles
299
+ * first; every other racer gets {@link releaseLoser}'d. Doesn't touch
300
+ * `target` itself -- callers (a subclass's own `onInit()` override)
301
+ * decide what to do with the winner.
302
+ */
303
+ async race() {
304
+ if (this.racers.length === 0) {
305
+ throw new Error('RacingHostAccessor: no racers registered to race');
306
+ }
307
+ const attempts = this.racers.map((racer) => ({
308
+ racer,
309
+ settled: this.startRacer(racer),
310
+ }));
311
+ let winner;
312
+ try {
313
+ winner = await Promise.any(attempts.map((a) => a.settled.then(() => a.racer)));
314
+ }
315
+ catch (err) {
316
+ throw new Error(`RacingHostAccessor: none of ${attempts.length} racer(s) could be initialized`, { cause: err });
317
+ }
318
+ for (const a of attempts) {
319
+ if (a.racer === winner)
320
+ continue;
321
+ this.releaseLoser(a.racer, a.settled);
322
+ }
323
+ return winner;
324
+ }
325
+ /**
326
+ * Called once per losing racer, right after a winner is picked. Default:
327
+ * deinit it once (if) its own `init()` eventually settles -- `deinit()` is
328
+ * a no-op for one that never finished or that already failed. Subclasses
329
+ * (e.g. `NThreadRaceAccessor`) can override this to *also* cancel an
330
+ * in-flight racer immediately, instead of only cleaning up after the fact.
331
+ */
332
+ releaseLoser(racer, settled) {
333
+ settled.then(() => racer.deinit().catch(() => { })).catch(() => { });
334
+ }
335
+ }
239
336
  /**
240
337
  * BootstrapHostAccessor is a specialized RedirectorHostAccessor designed to resolve circular dependencies
241
338
  * during the initialization of decorator/middleware chains.
@@ -263,7 +360,7 @@ export class BootstrapHostAccessor extends RedirectorHostAccessor {
263
360
  */
264
361
  export class CallRedirectorAccessor extends MiddlewareAccessor {
265
362
  async alloc(size, address = null, protection = MemoryProtection.READWRITE, allocationType = MemoryState.COMMIT | MemoryState.RESERVE) {
266
- const addressVal = address ? resolveAddress(address) : null;
363
+ const addressVal = address ? resolveAddress(address) : 0;
267
364
  const result = await this.root.call(Kernel32Impl.VirtualAlloc, addressVal, size, allocationType, protection);
268
365
  if (!result || Number(result) === 0) {
269
366
  throw new Error(`VirtualAlloc failed in remote process for size ${size}`);
@@ -271,7 +368,7 @@ export class CallRedirectorAccessor extends MiddlewareAccessor {
271
368
  return result;
272
369
  }
273
370
  allocSync(size, address = null, protection = MemoryProtection.READWRITE, allocationType = MemoryState.COMMIT | MemoryState.RESERVE) {
274
- const addressVal = address ? resolveAddress(address) : null;
371
+ const addressVal = address ? resolveAddress(address) : 0;
275
372
  const result = this.root.callSync(Kernel32Impl.VirtualAlloc, addressVal, size, allocationType, protection);
276
373
  if (!result || Number(result) === 0) {
277
374
  throw new Error(`VirtualAlloc failed in remote process for size ${size}`);
@@ -503,31 +600,66 @@ export class IndirectCallRedirectorAccessor extends CallRedirectorAccessor {
503
600
  });
504
601
  return info;
505
602
  }
603
+ hasMsvcrt = null;
604
+ async checkMsvcrt() {
605
+ if (this.hasMsvcrt !== null)
606
+ return this.hasMsvcrt;
607
+ const msvcrtBase = MsvcrtLibrary.baseAddress;
608
+ if (msvcrtBase.isNull() || Number(msvcrtBase) === 0) {
609
+ this.hasMsvcrt = false;
610
+ return false;
611
+ }
612
+ this.hasMsvcrt = await isModuleLoadedInProcess(this.root, msvcrtBase);
613
+ return this.hasMsvcrt;
614
+ }
615
+ checkMsvcrtSync() {
616
+ if (this.hasMsvcrt !== null)
617
+ return this.hasMsvcrt;
618
+ const msvcrtBase = MsvcrtLibrary.baseAddress;
619
+ if (msvcrtBase.isNull() || Number(msvcrtBase) === 0) {
620
+ this.hasMsvcrt = false;
621
+ return false;
622
+ }
623
+ this.hasMsvcrt = isModuleLoadedInProcessSync(this.root, msvcrtBase);
624
+ return this.hasMsvcrt;
625
+ }
506
626
  async alloc(size, address = null, protection = MemoryProtection.READWRITE, allocationType = MemoryState.COMMIT | MemoryState.RESERVE) {
507
627
  // If it's standard READWRITE memory without a specific requested address, use malloc for maximum redirection!
508
628
  if (protection === MemoryProtection.READWRITE &&
509
629
  (address === null || address === undefined)) {
510
- const result = await this.root.call(MsvcrtImpl.malloc, size);
511
- if (!result || Number(result) === 0) {
512
- throw new Error(`malloc failed in remote process for size ${size}`);
630
+ try {
631
+ if (await this.checkMsvcrt()) {
632
+ const result = await this.root.call(MsvcrtImpl.malloc, size);
633
+ if (result && Number(result) !== 0) {
634
+ const allocatedAddr = Number(result);
635
+ this.mallocs.set(allocatedAddr, size);
636
+ return allocatedAddr;
637
+ }
638
+ }
639
+ }
640
+ catch {
641
+ // Fallback to VirtualAlloc if msvcrt.dll malloc is not available in target process
513
642
  }
514
- const allocatedAddr = Number(result);
515
- this.mallocs.set(allocatedAddr, size);
516
- return allocatedAddr;
517
643
  }
518
- // Otherwise, fall back to VirtualAlloc (e.g. for executable memory blocks)
644
+ // Otherwise, fall back to VirtualAlloc (e.g. for executable memory blocks or if msvcrt not loaded)
519
645
  return super.alloc(size, address, protection, allocationType);
520
646
  }
521
647
  allocSync(size, address = null, protection = MemoryProtection.READWRITE, allocationType = MemoryState.COMMIT | MemoryState.RESERVE) {
522
648
  if (protection === MemoryProtection.READWRITE &&
523
649
  (address === null || address === undefined)) {
524
- const result = this.root.callSync(MsvcrtImpl.malloc, size);
525
- if (!result || Number(result) === 0) {
526
- throw new Error(`malloc failed in remote process for size ${size}`);
650
+ try {
651
+ if (this.checkMsvcrtSync()) {
652
+ const result = this.root.callSync(MsvcrtImpl.malloc, size);
653
+ if (result && Number(result) !== 0) {
654
+ const allocatedAddr = Number(result);
655
+ this.mallocs.set(allocatedAddr, size);
656
+ return allocatedAddr;
657
+ }
658
+ }
659
+ }
660
+ catch {
661
+ // Fallback to VirtualAlloc if msvcrt.dll malloc is not available in target process
527
662
  }
528
- const allocatedAddr = Number(result);
529
- this.mallocs.set(allocatedAddr, size);
530
- return allocatedAddr;
531
663
  }
532
664
  return super.allocSync(size, address, protection, allocationType);
533
665
  }
@@ -596,19 +728,43 @@ export class IndirectCallRedirectorAccessor extends CallRedirectorAccessor {
596
728
  export class MarshallingCallableAccessor extends MiddlewareAccessor {
597
729
  /**
598
730
  * Shared by call()/callSync() -- pure decision of "does this argument need
599
- * marshalling to a remote buffer, and if so, what bytes", with no I/O.
600
- * Returns `null` for args that pass through unmodified.
731
+ * marshalling to a remote buffer, and if so, what bytes and whether to copy
732
+ * remote modifications back to the caller's buffer/struct after the call".
601
733
  */
602
- static bufferToMarshal(type, val) {
734
+ static marshalInfo(funcName, type, val) {
603
735
  const normType = normalizeType(type);
604
736
  if (normType === 'cstring' && typeof val === 'string') {
605
- return Buffer.from(val + '\0', 'utf8');
606
- }
607
- else if (normType === 'cwstring' && typeof val === 'string') {
608
- return Buffer.from(val + '\0', 'utf16le');
737
+ return { buf: Buffer.from(val + '\0', 'utf8') };
738
+ }
739
+ if (normType === 'cwstring' && typeof val === 'string') {
740
+ return { buf: Buffer.from(val + '\0', 'utf16le') };
741
+ }
742
+ if (typeof val === 'string' &&
743
+ (normType === 'ptr' ||
744
+ normType === 'i64' ||
745
+ normType === 'u64' ||
746
+ normType === 'usize' ||
747
+ normType === 'size_t')) {
748
+ const trimmed = val.trim();
749
+ if (!/^-?0x[0-9a-fA-F]+$/.test(trimmed)) {
750
+ const isAnsi = funcName ? funcName.endsWith('A') : false;
751
+ return {
752
+ buf: Buffer.from(val + '\0', isAnsi ? 'utf8' : 'utf16le'),
753
+ };
754
+ }
609
755
  }
610
- else if (val && typeof val === 'object' && Buffer.isBuffer(val)) {
611
- return val;
756
+ if (val && typeof val === 'object') {
757
+ if (Buffer.isBuffer(val)) {
758
+ return { buf: val, writeBackTarget: val };
759
+ }
760
+ if (Buffer.isBuffer(val._backingBuffer)) {
761
+ const backing = val._backingBuffer;
762
+ return { buf: backing, writeBackTarget: backing };
763
+ }
764
+ if (ArrayBuffer.isView(val)) {
765
+ const viewBuf = Buffer.from(val.buffer, val.byteOffset, val.byteLength);
766
+ return { buf: viewBuf, writeBackTarget: viewBuf };
767
+ }
612
768
  }
613
769
  return null;
614
770
  }
@@ -617,16 +773,43 @@ export class MarshallingCallableAccessor extends MiddlewareAccessor {
617
773
  const tempAllocations = [];
618
774
  try {
619
775
  const signatureArgs = func.args || [];
620
- for (let i = 0; i < signatureArgs.length; i++) {
621
- const buf = MarshallingCallableAccessor.bufferToMarshal(signatureArgs[i], args[i]);
622
- if (!buf)
776
+ const totalArgs = Math.max(signatureArgs.length, args.length);
777
+ for (let i = 0; i < totalArgs; i++) {
778
+ const info = MarshallingCallableAccessor.marshalInfo(func.name, signatureArgs[i] || '', args[i]);
779
+ if (!info) {
780
+ if (typeof args[i] === 'string' &&
781
+ /^-?0x[0-9a-fA-F]+$/.test(args[i].trim())) {
782
+ modifiedArgs[i] = Number(BigInt(args[i].trim()));
783
+ }
623
784
  continue;
624
- const remoteAddr = Number(await this.root.alloc(buf.length));
625
- await this.root.write(remoteAddr, buf);
626
- tempAllocations.push({ address: remoteAddr, size: buf.length });
785
+ }
786
+ const allocLen = Math.max(info.buf.length, 1);
787
+ const remoteAddr = Number(await this.root.alloc(allocLen));
788
+ if (info.buf.length > 0) {
789
+ await this.root.write(remoteAddr, info.buf);
790
+ }
791
+ tempAllocations.push({
792
+ address: remoteAddr,
793
+ size: allocLen,
794
+ writeBackTarget: info.writeBackTarget,
795
+ });
627
796
  modifiedArgs[i] = remoteAddr;
628
797
  }
629
- return await this.backend.call(func, ...modifiedArgs);
798
+ const result = await this.backend.call(func, ...modifiedArgs);
799
+ for (const alloc of tempAllocations) {
800
+ if (alloc.writeBackTarget && alloc.writeBackTarget.length > 0) {
801
+ try {
802
+ const remoteBytes = await this.root.read(alloc.address, alloc.writeBackTarget.length);
803
+ if (remoteBytes && remoteBytes.length > 0) {
804
+ remoteBytes.copy(alloc.writeBackTarget, 0, 0, Math.min(remoteBytes.length, alloc.writeBackTarget.length));
805
+ }
806
+ }
807
+ catch {
808
+ // ignore write-back errors
809
+ }
810
+ }
811
+ }
812
+ return result;
630
813
  }
631
814
  finally {
632
815
  for (const alloc of tempAllocations) {
@@ -644,16 +827,43 @@ export class MarshallingCallableAccessor extends MiddlewareAccessor {
644
827
  const tempAllocations = [];
645
828
  try {
646
829
  const signatureArgs = func.args || [];
647
- for (let i = 0; i < signatureArgs.length; i++) {
648
- const buf = MarshallingCallableAccessor.bufferToMarshal(signatureArgs[i], args[i]);
649
- if (!buf)
830
+ const totalArgs = Math.max(signatureArgs.length, args.length);
831
+ for (let i = 0; i < totalArgs; i++) {
832
+ const info = MarshallingCallableAccessor.marshalInfo(func.name, signatureArgs[i] || '', args[i]);
833
+ if (!info) {
834
+ if (typeof args[i] === 'string' &&
835
+ /^-?0x[0-9a-fA-F]+$/.test(args[i].trim())) {
836
+ modifiedArgs[i] = Number(BigInt(args[i].trim()));
837
+ }
650
838
  continue;
651
- const remoteAddr = Number(this.root.allocSync(buf.length));
652
- this.root.writeSync(remoteAddr, buf);
653
- tempAllocations.push({ address: remoteAddr, size: buf.length });
839
+ }
840
+ const allocLen = Math.max(info.buf.length, 1);
841
+ const remoteAddr = Number(this.root.allocSync(allocLen));
842
+ if (info.buf.length > 0) {
843
+ this.root.writeSync(remoteAddr, info.buf);
844
+ }
845
+ tempAllocations.push({
846
+ address: remoteAddr,
847
+ size: allocLen,
848
+ writeBackTarget: info.writeBackTarget,
849
+ });
654
850
  modifiedArgs[i] = remoteAddr;
655
851
  }
656
- return this.backend.callSync(func, ...modifiedArgs);
852
+ const result = this.backend.callSync(func, ...modifiedArgs);
853
+ for (const alloc of tempAllocations) {
854
+ if (alloc.writeBackTarget && alloc.writeBackTarget.length > 0) {
855
+ try {
856
+ const remoteBytes = this.root.readSync(alloc.address, alloc.writeBackTarget.length);
857
+ if (remoteBytes && remoteBytes.length > 0) {
858
+ remoteBytes.copy(alloc.writeBackTarget, 0, 0, Math.min(remoteBytes.length, alloc.writeBackTarget.length));
859
+ }
860
+ }
861
+ catch {
862
+ // ignore write-back errors
863
+ }
864
+ }
865
+ }
866
+ return result;
657
867
  }
658
868
  finally {
659
869
  for (const alloc of tempAllocations) {
@@ -1161,33 +1371,41 @@ export class FileTransferReadAccessor extends MsvcrtDependentMiddlewareAccessor
1161
1371
  if (this.isInitializing || !this.tempFilePath) {
1162
1372
  return super.readAfterInit(address, size, offset);
1163
1373
  }
1164
- await this.ensureOpen();
1165
- const targetAddr = resolveAddress(address) + offset;
1166
- // Rewind so we overwrite from the start, dump target memory into the
1167
- // file, then flush so the local read below sees the fresh bytes.
1168
- await this.backend.call(MsvcrtImpl.rewind, this.hFile);
1169
- const written = await this.backend.call(MsvcrtImpl.fwrite, targetAddr, 1, size, this.hFile);
1170
- if (Number(written) !== size) {
1171
- throw new Error(`fwrite failed to write entire memory block of size ${size} (wrote ${written})`);
1172
- }
1173
- await this.backend.call(MsvcrtImpl.fflush, this.hFile);
1174
- // The file may still carry trailing bytes from a larger previous
1175
- // transfer, so take only the `size` bytes we just wrote.
1176
- return fs.readFileSync(this.tempFilePath).subarray(0, size);
1374
+ try {
1375
+ await this.ensureOpen();
1376
+ const targetAddr = resolveAddress(address) + offset;
1377
+ // Rewind so we overwrite from the start, dump target memory into the
1378
+ // file, then flush so the local read below sees the fresh bytes.
1379
+ await this.backend.call(MsvcrtImpl.rewind, this.hFile);
1380
+ const written = await this.backend.call(MsvcrtImpl.fwrite, targetAddr, 1, size, this.hFile);
1381
+ if (Number(written) === size) {
1382
+ await this.backend.call(MsvcrtImpl.fflush, this.hFile);
1383
+ return fs.readFileSync(this.tempFilePath).subarray(0, size);
1384
+ }
1385
+ }
1386
+ catch {
1387
+ // Fall through to backend read on any CRT staging or file lock failure
1388
+ }
1389
+ return super.readAfterInit(address, size, offset);
1177
1390
  }
1178
1391
  readAfterInitSync(address, size, offset = 0) {
1179
1392
  if (this.isInitializingSync || !this.tempFilePath) {
1180
1393
  return super.readAfterInitSync(address, size, offset);
1181
1394
  }
1182
- this.ensureOpenSync();
1183
- const targetAddr = resolveAddress(address) + offset;
1184
- this.backend.callSync(MsvcrtImpl.rewind, this.hFile);
1185
- const written = this.backend.callSync(MsvcrtImpl.fwrite, targetAddr, 1, size, this.hFile);
1186
- if (Number(written) !== size) {
1187
- throw new Error(`fwrite failed to write entire memory block of size ${size} (wrote ${written})`);
1395
+ try {
1396
+ this.ensureOpenSync();
1397
+ const targetAddr = resolveAddress(address) + offset;
1398
+ this.backend.callSync(MsvcrtImpl.rewind, this.hFile);
1399
+ const written = this.backend.callSync(MsvcrtImpl.fwrite, targetAddr, 1, size, this.hFile);
1400
+ if (Number(written) === size) {
1401
+ this.backend.callSync(MsvcrtImpl.fflush, this.hFile);
1402
+ return fs.readFileSync(this.tempFilePath).subarray(0, size);
1403
+ }
1404
+ }
1405
+ catch {
1406
+ // Fall through to backend read on any CRT staging or file lock failure
1188
1407
  }
1189
- this.backend.callSync(MsvcrtImpl.fflush, this.hFile);
1190
- return fs.readFileSync(this.tempFilePath).subarray(0, size);
1408
+ return super.readAfterInitSync(address, size, offset);
1191
1409
  }
1192
1410
  }
1193
1411
  /**
@@ -1307,17 +1525,22 @@ export class FileTransferWriteAccessor extends MsvcrtDependentMiddlewareAccessor
1307
1525
  }
1308
1526
  const targetAddr = resolveAddress(address) + offset;
1309
1527
  const buffer = data instanceof Buffer ? data : Buffer.from(data);
1310
- // Put the payload in the file (local, truncating write) BEFORE opening so
1311
- // fopen("rb") sees a valid file, then rewind the shared FILE* so fread
1312
- // re-reads the freshly written bytes from offset 0.
1313
- fs.writeFileSync(this.tempFilePath, buffer);
1314
- await this.ensureOpen();
1315
- await this.backend.call(MsvcrtImpl.rewind, this.hFile);
1316
- const readBytes = await this.backend.call(MsvcrtImpl.fread, targetAddr, 1, buffer.length, this.hFile);
1317
- if (Number(readBytes) !== buffer.length) {
1318
- throw new Error(`fread failed to read entire file block of size ${buffer.length} (read ${readBytes})`);
1319
- }
1320
- return Number(readBytes);
1528
+ try {
1529
+ // Put the payload in the file (local, truncating write) BEFORE opening so
1530
+ // fopen("rb") sees a valid file, then rewind the shared FILE* so fread
1531
+ // re-reads the freshly written bytes from offset 0.
1532
+ fs.writeFileSync(this.tempFilePath, buffer);
1533
+ await this.ensureOpen();
1534
+ await this.backend.call(MsvcrtImpl.rewind, this.hFile);
1535
+ const readBytes = await this.backend.call(MsvcrtImpl.fread, targetAddr, 1, buffer.length, this.hFile);
1536
+ if (Number(readBytes) === buffer.length) {
1537
+ return buffer.length;
1538
+ }
1539
+ }
1540
+ catch {
1541
+ // Fall through to backend write on any CRT staging or file lock failure
1542
+ }
1543
+ return super.writeAfterInit(address, data, offset);
1321
1544
  }
1322
1545
  writeAfterInitSync(address, data, offset = 0) {
1323
1546
  if (this.isInitializingSync || !this.tempFilePath) {
@@ -1325,14 +1548,19 @@ export class FileTransferWriteAccessor extends MsvcrtDependentMiddlewareAccessor
1325
1548
  }
1326
1549
  const targetAddr = resolveAddress(address) + offset;
1327
1550
  const buffer = data instanceof Buffer ? data : Buffer.from(data);
1328
- fs.writeFileSync(this.tempFilePath, buffer);
1329
- this.ensureOpenSync();
1330
- this.backend.callSync(MsvcrtImpl.rewind, this.hFile);
1331
- const readBytes = this.backend.callSync(MsvcrtImpl.fread, targetAddr, 1, buffer.length, this.hFile);
1332
- if (Number(readBytes) !== buffer.length) {
1333
- throw new Error(`fread failed to read entire file block of size ${buffer.length} (read ${readBytes})`);
1334
- }
1335
- return Number(readBytes);
1551
+ try {
1552
+ fs.writeFileSync(this.tempFilePath, buffer);
1553
+ this.ensureOpenSync();
1554
+ this.backend.callSync(MsvcrtImpl.rewind, this.hFile);
1555
+ const readBytes = this.backend.callSync(MsvcrtImpl.fread, targetAddr, 1, buffer.length, this.hFile);
1556
+ if (Number(readBytes) === buffer.length) {
1557
+ return buffer.length;
1558
+ }
1559
+ }
1560
+ catch {
1561
+ // Fall through to backend write on any CRT staging or file lock failure
1562
+ }
1563
+ return super.writeAfterInitSync(address, data, offset);
1336
1564
  }
1337
1565
  }
1338
1566
  /**