@ttsc/wasm 0.18.4 → 0.19.1

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.
package/src/MemFSError.ts CHANGED
@@ -27,6 +27,8 @@ function errnoForCode(code: string): number {
27
27
  return -2;
28
28
  case "EBADF":
29
29
  return -9;
30
+ case "EBUSY":
31
+ return -16;
30
32
  case "EEXIST":
31
33
  return -17;
32
34
  case "ENOTDIR":
@@ -37,6 +39,8 @@ function errnoForCode(code: string): number {
37
39
  return -22;
38
40
  case "ESPIPE":
39
41
  return -29;
42
+ case "ENOTEMPTY":
43
+ return -39;
40
44
  default:
41
45
  return -1;
42
46
  }
package/src/bootTtsc.ts CHANGED
@@ -109,56 +109,124 @@ async function bootTtscOnce(
109
109
 
110
110
  const host = options.host ?? createMemFS();
111
111
  const globalAny = globalThis as Record<string, unknown>;
112
- // Only install fs / process if they aren't already in place. A caller
113
- // booting a second wasm over the same MemFS reuses the same shims.
114
- if (!globalAny.fs) globalAny.fs = host.fs;
115
- if (!globalAny.process) globalAny.process = createProcessShim();
116
-
117
- // wasm_exec.js installs `globalThis.Go`. It also reads globalThis.fs at
118
- // module-eval time, so this import must follow the assignment above.
119
- importScripts(wasmExecUrl);
120
-
121
- // Race the Ready resolver against a Failed signal so a wasm-side fault
122
- // (e.g. `host.Expose` refusing a duplicate call) surfaces here instead of
123
- // hanging on `await ready` forever. `go.run` is fire-and-forget so its
124
- // own rejection cannot reach this promise without an explicit channel.
125
- const ready = new Promise<void>((resolve, reject) => {
126
- globalAny[apiName + "Ready"] = () => {
127
- delete globalAny[apiName + "Failed"];
128
- resolve();
129
- };
130
- globalAny[apiName + "Failed"] = (err: unknown) => {
131
- delete globalAny[apiName + "Ready"];
132
- reject(err instanceof Error ? err : new Error(String(err)));
133
- };
134
- });
135
-
136
- const goCtor = (globalAny as { Go?: new () => IGoInstance }).Go;
137
- if (typeof goCtor !== "function") {
138
- throw new Error(
139
- `bootTtsc: globalThis.Go was not installed by ${wasmExecUrl} — the file may not have loaded (CSP block, wrong content type, 404), or it is not the wasm_exec.js shipped with the Go toolchain.`,
140
- );
112
+ // Install fs / process only if they aren't already in place, and remember
113
+ // whether THIS attempt installed them. The Go runtime this boot starts reads
114
+ // `globalThis.fs` when it runs, so the returned `host` must be the exact host
115
+ // backing those globals. A caller booting a second wasm over the same MemFS
116
+ // (or reusing one host across retries) reuses the same shims. When an earlier
117
+ // failed attempt already installed a different host's shims, those are torn
118
+ // down on failure below so this attempt can install its own.
119
+ const installedFs = !globalAny.fs;
120
+ const installedProcess = !globalAny.process;
121
+ let processShim: unknown;
122
+ if (installedFs) globalAny.fs = host.fs;
123
+ if (installedProcess) {
124
+ processShim = createProcessShim();
125
+ globalAny.process = processShim;
141
126
  }
142
- const go = new goCtor();
143
127
 
144
- const response = await fetch(wasmUrl);
145
- if (!response.ok) {
146
- throw new Error(`bootTtsc: failed to fetch ${wasmUrl}: ${response.status}`);
147
- }
148
- const wasm = await WebAssembly.instantiateStreaming(
149
- response,
150
- go.importObject,
151
- );
152
- // go.run never resolves until the wasm exits; we don't await it.
153
- void go.run(wasm.instance);
154
- await ready;
155
-
156
- const api = (globalAny as Record<string, ITtscApi | undefined>)[apiName];
157
- if (!api)
158
- throw new Error(
159
- `bootTtsc: ${apiName} global was not set — was the wasm built with host.Expose(${JSON.stringify(apiName)}, ...)?`,
128
+ // Any failure after global installation must leave the globals as this
129
+ // attempt found them, so a retry installs its own host's fs (and the returned
130
+ // host keeps matching the runtime's filesystem). Only remove what we
131
+ // installed and only while it is still ours — never stomp a foreign fs or one
132
+ // a concurrently-booted runtime already claimed.
133
+ const restoreGlobals = (): void => {
134
+ if (installedFs && globalAny.fs === host.fs) delete globalAny.fs;
135
+ if (installedProcess && globalAny.process === processShim)
136
+ delete globalAny.process;
137
+ };
138
+
139
+ try {
140
+ // wasm_exec.js installs `globalThis.Go`. It also reads globalThis.fs at
141
+ // module-eval time, so this import must follow the assignment above.
142
+ importScripts(wasmExecUrl);
143
+
144
+ // Race the Ready resolver against a Failed signal so a wasm-side fault
145
+ // (e.g. `host.Expose` refusing a duplicate call) surfaces here instead of
146
+ // hanging on `await ready` forever.
147
+ let readyCb!: () => void;
148
+ let failedCb!: (err: unknown) => void;
149
+ const ready = new Promise<void>((resolve, reject) => {
150
+ readyCb = () => {
151
+ delete globalAny[apiName + "Failed"];
152
+ resolve();
153
+ };
154
+ failedCb = (err: unknown) => {
155
+ delete globalAny[apiName + "Ready"];
156
+ reject(err instanceof Error ? err : new Error(String(err)));
157
+ };
158
+ globalAny[apiName + "Ready"] = readyCb;
159
+ globalAny[apiName + "Failed"] = failedCb;
160
+ });
161
+
162
+ const goCtor = (globalAny as { Go?: new () => IGoInstance }).Go;
163
+ if (typeof goCtor !== "function") {
164
+ throw new Error(
165
+ `bootTtsc: globalThis.Go was not installed by ${wasmExecUrl} — the file may not have loaded (CSP block, wrong content type, 404), or it is not the wasm_exec.js shipped with the Go toolchain.`,
166
+ );
167
+ }
168
+ const go = new goCtor();
169
+
170
+ const response = await fetch(wasmUrl);
171
+ if (!response.ok) {
172
+ throw new Error(
173
+ `bootTtsc: failed to fetch ${wasmUrl}: ${response.status}`,
174
+ );
175
+ }
176
+ const wasm = await WebAssembly.instantiateStreaming(
177
+ response,
178
+ go.importObject,
179
+ );
180
+
181
+ // A normal host keeps `go.run` pending forever after signaling Ready, so a
182
+ // settlement (fulfil OR reject) BEFORE Ready means the Go runtime exited or
183
+ // panicked before it could register — e.g. an early `host.Expose` panic
184
+ // that never reached the Failed bridge. Race that early exit against
185
+ // readiness so the boot rejects with an actionable cause instead of hanging.
186
+ // The standard Go runner discards the exit code, so an unknown early exit
187
+ // can only synthesize a generic message; known host validation failures
188
+ // reject through Failed above and keep their original cause.
189
+ const runPromise = Promise.resolve(go.run(wasm.instance));
190
+ const earlyExit = runPromise.then(
191
+ () => {
192
+ throw new Error(
193
+ `bootTtsc: the ${apiName} wasm runtime exited before signaling readiness (the host may have panicked; check the wasm stderr).`,
194
+ );
195
+ },
196
+ (err: unknown) => {
197
+ throw new Error(
198
+ `bootTtsc: the ${apiName} wasm runtime failed before signaling readiness: ${
199
+ err instanceof Error ? err.message : String(err)
200
+ }`,
201
+ );
202
+ },
160
203
  );
161
- return { api, host };
204
+ // When Ready wins, the long-running runtime's eventual `go.run` settlement
205
+ // must not surface as an unhandled rejection. Attach a terminal handler to
206
+ // the losing branch.
207
+ earlyExit.catch(() => {});
208
+
209
+ try {
210
+ await Promise.race([ready, earlyExit]);
211
+ } finally {
212
+ // Drop this attempt's readiness bridge so a later boot for the same
213
+ // apiName installs a clean pair and no stale resolver survives.
214
+ if (globalAny[apiName + "Ready"] === readyCb)
215
+ delete globalAny[apiName + "Ready"];
216
+ if (globalAny[apiName + "Failed"] === failedCb)
217
+ delete globalAny[apiName + "Failed"];
218
+ }
219
+
220
+ const api = (globalAny as Record<string, ITtscApi | undefined>)[apiName];
221
+ if (!api)
222
+ throw new Error(
223
+ `bootTtsc: ${apiName} global was not set — was the wasm built with host.Expose(${JSON.stringify(apiName)}, ...)?`,
224
+ );
225
+ return { api, host };
226
+ } catch (err) {
227
+ restoreGlobals();
228
+ throw err;
229
+ }
162
230
  }
163
231
 
164
232
  /**
@@ -160,6 +160,59 @@ export function createMemFS(): IMemFSHost {
160
160
  }
161
161
  }
162
162
 
163
+ /** Absolute parent directory of a normalized path (`/` for a top-level path). */
164
+ function parentDir(norm: string): string {
165
+ const idx = norm.lastIndexOf("/");
166
+ return idx <= 0 ? "/" : norm.slice(0, idx);
167
+ }
168
+
169
+ /** True when `dir` has at least one descendant node in the tree. */
170
+ function hasChildren(dir: string): boolean {
171
+ const prefix = dir === "/" ? "/" : dir + "/";
172
+ for (const key of nodes.keys()) {
173
+ if (key !== dir && key.startsWith(prefix)) return true;
174
+ }
175
+ return false;
176
+ }
177
+
178
+ /**
179
+ * Grow or shrink a file's byte buffer to exactly `length`, zero-filling any
180
+ * extension. Callers validate `length >= 0` first.
181
+ */
182
+ function resizeFileData(data: Uint8Array, length: number): Uint8Array {
183
+ const next = new Uint8Array(length);
184
+ next.set(data.subarray(0, Math.min(length, data.byteLength)));
185
+ return next;
186
+ }
187
+
188
+ /**
189
+ * Move the entire subtree rooted at `src` to `dest`, overwriting any existing
190
+ * `dest` node. Every descendant key is re-parented so no old-prefix node is
191
+ * left orphaned, and open descriptors that referenced a moved path follow to
192
+ * the new location (a rename must not strand an fd's inode).
193
+ */
194
+ function moveSubtree(src: string, dest: string): void {
195
+ const srcPrefix = src + "/";
196
+ const moves: Array<[string, INode]> = [];
197
+ for (const [key, node] of nodes) {
198
+ if (key === src) moves.push([dest, node]);
199
+ else if (key.startsWith(srcPrefix))
200
+ moves.push([dest + "/" + key.slice(srcPrefix.length), node]);
201
+ }
202
+ // Delete the whole source subtree first so a nested overwrite cannot leave
203
+ // a stale descendant behind, then reinsert at the destination prefix. Any
204
+ // pre-existing `dest` node is replaced by the reinsert.
205
+ for (const key of [...nodes.keys()]) {
206
+ if (key === src || key.startsWith(srcPrefix)) nodes.delete(key);
207
+ }
208
+ for (const [key, node] of moves) nodes.set(key, node);
209
+ for (const entry of fdTable.values()) {
210
+ if (entry.path === src) entry.path = dest;
211
+ else if (entry.path.startsWith(srcPrefix))
212
+ entry.path = dest + "/" + entry.path.slice(srcPrefix.length);
213
+ }
214
+ }
215
+
163
216
  function mkdirp(p: string): void {
164
217
  const segments = normalize(p).split("/").filter(Boolean);
165
218
  let cursor = "";
@@ -281,10 +334,8 @@ export function createMemFS(): IMemFSHost {
281
334
  console.error("[wasm]", line);
282
335
  return buf.length;
283
336
  }
284
- // Open-file fds (>= 100): append to the underlying file. This is the
285
- // path the wasm host's runWithCapturedIO uses to capture plugin
286
- // stdout/stderr via /tmp/* temp files (os.Pipe is not implemented on
287
- // js/wasm so we mirror its semantics via files).
337
+ // Open-file fds (>= 100): append to the underlying file. Browser-hosted
338
+ // tools use this path for ordinary virtual files and explicit outputs.
288
339
  const entry = fdTable.get(fd);
289
340
  if (entry) {
290
341
  const node = nodes.get(entry.path);
@@ -508,10 +559,19 @@ export function createMemFS(): IMemFSHost {
508
559
 
509
560
  unlink(p, callback) {
510
561
  const norm = normalize(p);
511
- if (!nodes.has(norm)) {
562
+ const node = nodes.get(norm);
563
+ if (!node) {
512
564
  callback(new MemFSError("ENOENT", "unlink", norm));
513
565
  return;
514
566
  }
567
+ // POSIX unlink refuses directories (EISDIR/EPERM). Go's os.Remove tries
568
+ // unlink first and only falls back to rmdir when it fails, so a false
569
+ // success here would delete just the directory node and orphan every
570
+ // descendant. Reject so the rmdir path (which validates emptiness) runs.
571
+ if (node.kind === "dir") {
572
+ callback(new MemFSError("EISDIR", "unlink", norm));
573
+ return;
574
+ }
515
575
  nodes.delete(norm);
516
576
  callback(null);
517
577
  },
@@ -523,14 +583,73 @@ export function createMemFS(): IMemFSHost {
523
583
  callback(new MemFSError("ENOENT", "rename", src));
524
584
  return;
525
585
  }
586
+ if (src === "/") {
587
+ callback(new MemFSError("EBUSY", "rename", src));
588
+ return;
589
+ }
526
590
  const dest = normalize(to);
527
- nodes.delete(src);
528
- nodes.set(dest, node);
591
+ // Renaming a path onto itself is a defined no-op success.
592
+ if (src === dest) {
593
+ callback(null);
594
+ return;
595
+ }
596
+ // A directory cannot be moved inside itself or its own descendants.
597
+ if (node.kind === "dir" && dest.startsWith(src + "/")) {
598
+ callback(new MemFSError("EINVAL", "rename", src));
599
+ return;
600
+ }
601
+ // The destination's parent must already exist as a directory.
602
+ const parent = nodes.get(parentDir(dest));
603
+ if (!parent) {
604
+ callback(new MemFSError("ENOENT", "rename", dest));
605
+ return;
606
+ }
607
+ if (parent.kind !== "dir") {
608
+ callback(new MemFSError("ENOTDIR", "rename", dest));
609
+ return;
610
+ }
611
+ // Reconcile against an existing destination before mutating anything so a
612
+ // rejected rename leaves the tree untouched (no partial state).
613
+ const destNode = nodes.get(dest);
614
+ if (destNode) {
615
+ if (node.kind === "file") {
616
+ if (destNode.kind === "dir") {
617
+ callback(new MemFSError("EISDIR", "rename", dest));
618
+ return;
619
+ }
620
+ } else if (destNode.kind !== "dir") {
621
+ callback(new MemFSError("ENOTDIR", "rename", dest));
622
+ return;
623
+ } else if (hasChildren(dest)) {
624
+ callback(new MemFSError("ENOTEMPTY", "rename", dest));
625
+ return;
626
+ }
627
+ }
628
+ moveSubtree(src, dest);
529
629
  callback(null);
530
630
  },
531
631
 
532
632
  rmdir(p, callback) {
533
- this.unlink(p, callback);
633
+ const norm = normalize(p);
634
+ const node = nodes.get(norm);
635
+ if (!node) {
636
+ callback(new MemFSError("ENOENT", "rmdir", norm));
637
+ return;
638
+ }
639
+ if (node.kind !== "dir") {
640
+ callback(new MemFSError("ENOTDIR", "rmdir", norm));
641
+ return;
642
+ }
643
+ if (norm === "/") {
644
+ callback(new MemFSError("EBUSY", "rmdir", norm));
645
+ return;
646
+ }
647
+ if (hasChildren(norm)) {
648
+ callback(new MemFSError("ENOTEMPTY", "rmdir", norm));
649
+ return;
650
+ }
651
+ nodes.delete(norm);
652
+ callback(null);
534
653
  },
535
654
 
536
655
  chmod(_p, _mode, callback) {
@@ -560,10 +679,51 @@ export function createMemFS(): IMemFSHost {
560
679
  readlink(_p, callback) {
561
680
  callback(new MemFSError("EINVAL", "readlink"), "");
562
681
  },
563
- truncate(_p, _length, callback) {
682
+ truncate(p, length, callback) {
683
+ const norm = normalize(p);
684
+ const node = nodes.get(norm);
685
+ if (!node) {
686
+ callback(new MemFSError("ENOENT", "truncate", norm));
687
+ return;
688
+ }
689
+ if (node.kind !== "file") {
690
+ callback(new MemFSError("EISDIR", "truncate", norm));
691
+ return;
692
+ }
693
+ if (!Number.isInteger(length) || length < 0) {
694
+ callback(new MemFSError("EINVAL", "truncate", norm));
695
+ return;
696
+ }
697
+ node.data = resizeFileData(node.data, length);
698
+ node.mtimeMs = Date.now();
564
699
  callback(null);
565
700
  },
566
- ftruncate(_fd, _length, callback) {
701
+ ftruncate(fd, length, callback) {
702
+ // Pipe ends and the reserved stdout/stderr fds have no truncatable file.
703
+ if (pipes.has(fd)) {
704
+ callback(new MemFSError("EINVAL", "ftruncate"));
705
+ return;
706
+ }
707
+ const entry = fdTable.get(fd);
708
+ if (!entry) {
709
+ callback(new MemFSError("EBADF", "ftruncate"));
710
+ return;
711
+ }
712
+ if (entry.isStdout || entry.isStderr) {
713
+ callback(new MemFSError("EINVAL", "ftruncate"));
714
+ return;
715
+ }
716
+ const node = nodes.get(entry.path);
717
+ if (!node || node.kind !== "file") {
718
+ callback(new MemFSError("EINVAL", "ftruncate", entry.path));
719
+ return;
720
+ }
721
+ if (!Number.isInteger(length) || length < 0) {
722
+ callback(new MemFSError("EINVAL", "ftruncate", entry.path));
723
+ return;
724
+ }
725
+ node.data = resizeFileData(node.data, length);
726
+ node.mtimeMs = Date.now();
567
727
  callback(null);
568
728
  },
569
729
  pipe2(_flags, callback) {
@@ -1,58 +0,0 @@
1
- package checker
2
-
3
- // Compile-time guard: the signature-introspection surface that typia's
4
- // plain.classify from/new construction-strategy detection depends on must
5
- // compose end-to-end through the shim.
6
- //
7
- // 0.15.5 exposed a CONSUMER of a *Signature (Checker_getMinArgumentCount)
8
- // without the PRODUCER that yields one (Checker_getSignaturesOfType): a
9
- // *Signature was nameable (the `Signature` alias) but unobtainable. The closure
10
- // auditor checks that every escaping type can be NAMED, not that it can be
11
- // PRODUCED, so it did not catch the gap and the feature stayed blocked.
12
- //
13
- // This lives in a NORMAL (non-_test) file on purpose. The shim/checker package
14
- // is a nested Go module that no CI job runs `go test` against, so a _test.go
15
- // guard would never be compiled and would silently stop guarding. As ordinary
16
- // package code it is type-checked by every build that links the shim (typia's
17
- // native plugin, ttsc's lint engine), so dropping or renaming any leg below
18
- // turns those builds red. It never runs (assigned to blank, never called).
19
- var _ = func(c *Checker, instanceType *Type) {
20
- // class instance type -> class symbol -> constructor (static) type, which
21
- // carries both the construct signatures and the static `from` member.
22
- classSymbol := Type_getTypeNameSymbol(instanceType)
23
- ctorType := Checker_getTypeOfSymbol(c, classSymbol)
24
-
25
- // `new C(seed)` strategy: construct signatures -> arity (min args +
26
- // parameter count, together disambiguating `()` from `(x?)`) -> return type
27
- // -> the seed parameter's type. The seed is Signature_parameters[0]'s type,
28
- // EXCEPT a rest-ONLY parameter `(...xs: S[])` — when
29
- // `hasRestParameter && parameterCount == 1` — whose seed is the rest ELEMENT
30
- // (getRestTypeOfSignature). A leading-required + rest-tail `(s, ...r)` still
31
- // has hasRestParameter true but its seed is the first parameter S.
32
- for _, sig := range Checker_getSignaturesOfType(c, ctorType, SignatureKindConstruct) {
33
- _ = Checker_getMinArgumentCount(c, sig)
34
- _ = Signature_parameterCount(sig)
35
- _ = Checker_getReturnTypeOfSignature(c, sig)
36
- if Signature_hasRestParameter(sig) {
37
- _ = Checker_getRestTypeOfSignature(c, sig)
38
- }
39
- for _, p := range Signature_parameters(sig) {
40
- _ = Checker_getTypeOfSymbol(c, p)
41
- }
42
- }
43
-
44
- // `C.from(seed)` strategy: static `from` member -> its call signatures, read
45
- // identically (arity, return type, seed parameter / rest element type).
46
- fromType := Checker_getTypeOfPropertyOfType(c, ctorType, "from")
47
- for _, sig := range Checker_getSignaturesOfType(c, fromType, SignatureKindCall) {
48
- _ = Checker_getMinArgumentCount(c, sig)
49
- _ = Signature_parameterCount(sig)
50
- _ = Checker_getReturnTypeOfSignature(c, sig)
51
- if Signature_hasRestParameter(sig) {
52
- _ = Checker_getRestTypeOfSignature(c, sig)
53
- }
54
- for _, p := range Signature_parameters(sig) {
55
- _ = Checker_getTypeOfSymbol(c, p)
56
- }
57
- }
58
- }