@superblocksteam/sdk 2.0.132-next.0 → 2.0.133-next.0

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.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { buildFatalExitLog, FATAL_EXIT_EVENT, parseIsWarm, } from "./fatal-exit.mjs";
2
+ import { buildFatalExitLog, FATAL_EXIT_EVENT, isRecoverableShutdownError, parseIsWarm, } from "./fatal-exit.mjs";
3
3
  describe("parseIsWarm", () => {
4
4
  it("treats the same truthy spellings as the entrypoint shell", () => {
5
5
  for (const value of ["true", "1", "yes", "on", "TRUE", "Yes", " on "]) {
@@ -65,4 +65,41 @@ describe("buildFatalExitLog", () => {
65
65
  expect(line).toBe("Dev server fatal exit event=dev_server_fatal_exit handler=uncaughtException exit_code=1 signal=none is_warm=false");
66
66
  });
67
67
  });
68
+ describe("isRecoverableShutdownError", () => {
69
+ it("matches the connection-closed SocketError codes (8 and 10)", () => {
70
+ // The exact object socket.ts rejects pending requests with on an abnormal
71
+ // close, plus the SocketErrorException shape for a request-after-close.
72
+ expect(isRecoverableShutdownError({
73
+ code: 8,
74
+ message: "[internal] Connection closed",
75
+ })).toBe(true);
76
+ expect(isRecoverableShutdownError({
77
+ code: 10,
78
+ message: "[internal] Connection closed",
79
+ })).toBe(true);
80
+ });
81
+ it("matches Vite's ERR_CLOSED_SERVER (a string code, raced against close)", () => {
82
+ expect(isRecoverableShutdownError({
83
+ code: "ERR_CLOSED_SERVER",
84
+ message: "The server is being restarted or closed.",
85
+ })).toBe(true);
86
+ });
87
+ it("does not match other socket error codes (those are genuine faults)", () => {
88
+ // 2 = unknown method, 3 = handler exception, 7 = no-response timeout.
89
+ for (const code of [2, 3, 7]) {
90
+ expect(isRecoverableShutdownError({ code, message: "boom" })).toBe(false);
91
+ }
92
+ });
93
+ it("does not match plain errors or non-error shapes", () => {
94
+ expect(isRecoverableShutdownError(new Error("boom"))).toBe(false);
95
+ expect(isRecoverableShutdownError(undefined)).toBe(false);
96
+ expect(isRecoverableShutdownError(null)).toBe(false);
97
+ expect(isRecoverableShutdownError("Connection closed")).toBe(false);
98
+ expect(isRecoverableShutdownError({ message: "no code" })).toBe(false);
99
+ // A numeric code as a string ("8") is not our socket code, and an unrelated
100
+ // string code is not Vite's — neither is recoverable.
101
+ expect(isRecoverableShutdownError({ code: "8" })).toBe(false);
102
+ expect(isRecoverableShutdownError({ code: "ERR_OTHER" })).toBe(false);
103
+ });
104
+ });
68
105
  //# sourceMappingURL=fatal-exit.test.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"fatal-exit.test.mjs","sourceRoot":"","sources":["../../src/dev-utils/fatal-exit.test.mts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"fatal-exit.test.mjs","sourceRoot":"","sources":["../../src/dev-utils/fatal-exit.test.mts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superblocksteam/sdk",
3
- "version": "2.0.132-next.0",
3
+ "version": "2.0.133-next.0",
4
4
  "description": "Superblocks JS SDK",
5
5
  "homepage": "https://www.superblocks.com",
6
6
  "license": "Superblocks Community Software License",
@@ -48,11 +48,11 @@
48
48
  "vite-tsconfig-paths": "^6.0.4",
49
49
  "winston": "^3.17.0",
50
50
  "yaml": "^2.7.1",
51
- "@superblocksteam/library-shared": "2.0.132-next.0",
52
- "@superblocksteam/shared": "0.9593.0",
53
- "@superblocksteam/telemetry": "2.0.132-next.0",
54
- "@superblocksteam/util": "2.0.132-next.0",
55
- "@superblocksteam/vite-plugin-file-sync": "2.0.132-next.0"
51
+ "@superblocksteam/library-shared": "2.0.133-next.0",
52
+ "@superblocksteam/shared": "0.9594.0",
53
+ "@superblocksteam/telemetry": "2.0.133-next.0",
54
+ "@superblocksteam/util": "2.0.133-next.0",
55
+ "@superblocksteam/vite-plugin-file-sync": "2.0.133-next.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@eslint/js": "^9.39.2",
@@ -96,6 +96,40 @@ export function normalizeBootKindTag(
96
96
  : "other";
97
97
  }
98
98
 
99
+ /**
100
+ * Where a recoverable shutdown error was caught and suppressed instead of
101
+ * exiting the process. A closed enum so the `handler` label on
102
+ * `dev_server_recoverable_shutdown_error_total` stays low-cardinality.
103
+ */
104
+ export type RecoverableShutdownHandler =
105
+ | "uncaughtException"
106
+ | "unhandledRejection"
107
+ | "vite_teardown";
108
+
109
+ /**
110
+ * Allowlist of the close codes `isRecoverableShutdownError` matches: socket
111
+ * codes 8/10 (`packages/shared/src/socket/socket.ts`) and Vite's
112
+ * `ERR_CLOSED_SERVER`. Bounds the `code` metric label so the series count stays
113
+ * fixed even if the predicate ever lets an unexpected code through.
114
+ */
115
+ const RECOVERABLE_SHUTDOWN_CODE_TAGS = new Set<string>([
116
+ "8",
117
+ "10",
118
+ "ERR_CLOSED_SERVER",
119
+ ]);
120
+
121
+ /**
122
+ * Normalize a recoverable-shutdown error's `code` into a bounded metric-label
123
+ * value: known close codes pass through (keeping the diagnostic facet),
124
+ * anything else collapses to `"other"`, and a missing/non-primitive code maps
125
+ * to `"none"`.
126
+ */
127
+ export function normalizeRecoverableShutdownCodeTag(code: unknown): string {
128
+ if (typeof code !== "number" && typeof code !== "string") return "none";
129
+ const tag = String(code);
130
+ return RECOVERABLE_SHUTDOWN_CODE_TAGS.has(tag) ? tag : "other";
131
+ }
132
+
99
133
  /** Dev-server endpoints that produce per-request metrics. */
100
134
  export type DevServerEndpoint =
101
135
  | "_sb_health"
@@ -408,6 +442,39 @@ class DevServerMetrics {
408
442
  });
409
443
  }
410
444
 
445
+ /**
446
+ * Records a recoverable shutdown error that was suppressed (logged at warn,
447
+ * process left alive) instead of triggering a fatal exit. This is the
448
+ * alertable replacement for the `dev_server_fatal_exit` log token the old
449
+ * fatal path emitted: without it, the only way to see these errors (or notice
450
+ * their rate regressing) is a log search.
451
+ *
452
+ * Both labels are low-cardinality: `handler` is a closed enum, and the error's
453
+ * `code` is normalized to the known socket / Vite close codes (or
454
+ * `other`/`none`).
455
+ */
456
+ recordRecoverableShutdownError(
457
+ handler: RecoverableShutdownHandler,
458
+ reason: unknown,
459
+ ): void {
460
+ const code =
461
+ typeof reason === "object" && reason !== null && "code" in reason
462
+ ? (reason as { code: unknown }).code
463
+ : undefined;
464
+ const labels = {
465
+ handler,
466
+ code: normalizeRecoverableShutdownCodeTag(code),
467
+ };
468
+ this.record(() => {
469
+ getMeter()
470
+ .createCounter("dev_server_recoverable_shutdown_error_total", {
471
+ description:
472
+ "Count of recoverable shutdown errors suppressed instead of exiting, by handler and close code.",
473
+ })
474
+ .add(1, labels);
475
+ });
476
+ }
477
+
411
478
  /**
412
479
  * Records an ENOSPC disk-recovery attempt and its outcome. Lets dashboards
413
480
  * and alerts answer "how often is the live-edit PVC filling up, and is
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
3
3
  import {
4
4
  normalizeBootKindTag,
5
5
  normalizeNpmErrorCodeTag,
6
+ normalizeRecoverableShutdownCodeTag,
6
7
  } from "./dev-server-metrics.mjs";
7
8
 
8
9
  describe("normalizeBootKindTag (metric cardinality bound)", () => {
@@ -59,3 +60,28 @@ describe("normalizeNpmErrorCodeTag (metric cardinality bound)", () => {
59
60
  expect(normalizeNpmErrorCodeTag("")).toBe("none");
60
61
  });
61
62
  });
63
+
64
+ describe("normalizeRecoverableShutdownCodeTag (metric cardinality bound)", () => {
65
+ it("passes through the known socket / Vite close codes", () => {
66
+ // Numeric socket codes and Vite's string code are stringified for the label.
67
+ expect(normalizeRecoverableShutdownCodeTag(8)).toBe("8");
68
+ expect(normalizeRecoverableShutdownCodeTag(10)).toBe("10");
69
+ expect(normalizeRecoverableShutdownCodeTag("ERR_CLOSED_SERVER")).toBe(
70
+ "ERR_CLOSED_SERVER",
71
+ );
72
+ });
73
+
74
+ it("maps any other code to 'other' (cardinality guard)", () => {
75
+ // Should the predicate ever let an unexpected code through, the label must
76
+ // not mint a new time series per distinct value.
77
+ expect(normalizeRecoverableShutdownCodeTag(7)).toBe("other");
78
+ expect(normalizeRecoverableShutdownCodeTag("ECONNRESET")).toBe("other");
79
+ expect(normalizeRecoverableShutdownCodeTag("`;rm -rf /#")).toBe("other");
80
+ });
81
+
82
+ it("maps a missing or non-primitive code to 'none'", () => {
83
+ expect(normalizeRecoverableShutdownCodeTag(undefined)).toBe("none");
84
+ expect(normalizeRecoverableShutdownCodeTag(null)).toBe("none");
85
+ expect(normalizeRecoverableShutdownCodeTag({})).toBe("none");
86
+ });
87
+ });
@@ -65,7 +65,11 @@ import {
65
65
  type DevServerFailureType,
66
66
  devServerMetrics,
67
67
  } from "./dev-server-metrics.mjs";
68
- import { buildFatalExitLog, parseIsWarm } from "./fatal-exit.mjs";
68
+ import {
69
+ buildFatalExitLog,
70
+ isRecoverableShutdownError,
71
+ parseIsWarm,
72
+ } from "./fatal-exit.mjs";
69
73
  import {
70
74
  formatViteDevServerStartedLog,
71
75
  logViteBuildError,
@@ -626,7 +630,7 @@ export async function createDevServer({
626
630
  let isViteServerInitialized = false;
627
631
  let viteResolve: (value: void | PromiseLike<void>) => void;
628
632
  let viteReject: (reason?: any) => void;
629
- let vitePromise = new Promise((resolve, reject) => {
633
+ const vitePromise = new Promise((resolve, reject) => {
630
634
  viteResolve = resolve;
631
635
  viteReject = reject;
632
636
  });
@@ -661,6 +665,68 @@ export async function createDevServer({
661
665
  },
662
666
  });
663
667
 
668
+ // Single authoritative Vite teardown. Both the graceful-shutdown path and the
669
+ // /_sb_disconnect restart path go through here so the ordering that prevents
670
+ // ERR_CLOSED_SERVER / socket-close races lives in one place: stop new work
671
+ // FIRST (flip the initialized flag so the request gate rejects, drop the Vite
672
+ // middleware from the router, cancel pending sync), THEN close. Nothing calls
673
+ // into Vite after close() that way, so it cannot throw "request after close".
674
+ // A recoverable close error is logged, not rethrown — the global fatal handler
675
+ // treats the same shapes as non-fatal, but swallowing here keeps a benign
676
+ // teardown race off the error path entirely (APPS-4797).
677
+ //
678
+ // Single-flight guard: a SIGTERM graceful shutdown can land while a
679
+ // /_sb_disconnect restart is already awaiting close(), and both reach the
680
+ // worker with viteCreationResults still set (it is nulled only after close()
681
+ // resolves). Without the guard both would close() and dispose() the same Vite
682
+ // instance: the second close() throws ERR_CLOSED_SERVER and dispose() runs
683
+ // twice. Sharing one in-flight promise makes the second caller await the first
684
+ // teardown instead of starting its own.
685
+ let viteClosePromise: Promise<void> | null = null;
686
+ function closeViteServer(): Promise<void> {
687
+ if (!viteClosePromise) {
688
+ viteClosePromise = teardownViteServer().finally(() => {
689
+ viteClosePromise = null;
690
+ });
691
+ }
692
+ return viteClosePromise;
693
+ }
694
+
695
+ async function teardownViteServer(): Promise<void> {
696
+ if (!viteCreationResults) {
697
+ return;
698
+ }
699
+ const { viteServer, dispose, routeIndex } = viteCreationResults;
700
+ isViteServerInitialized = false;
701
+ if (routeIndex !== -1) {
702
+ // Remove the Vite middleware so express stops handing requests to a server
703
+ // we are about to close. `app._router` is untyped express internals; this
704
+ // cast matches the existing usage in this file.
705
+ (app._router as IRouter).stack.splice(routeIndex, 1);
706
+ }
707
+ // Cancel pending file sync as part of teardown. This only runs when Vite is
708
+ // up (the guard above early-returns otherwise). The restart path used to call
709
+ // this unconditionally, but it exits the process immediately afterward, so an
710
+ // uncancelled in-flight sync cannot persist; when viteCreationResults is
711
+ // already null the skipped call would have been a no-op anyway.
712
+ syncService?.cancelPendingSync();
713
+ try {
714
+ await viteServer.close();
715
+ } catch (e) {
716
+ if (!isRecoverableShutdownError(e)) {
717
+ throw e;
718
+ }
719
+ logger.warn(
720
+ "Ignoring recoverable error during Vite teardown",
721
+ getErrorMeta(e),
722
+ );
723
+ devServerMetrics.recordRecoverableShutdownError("vite_teardown", e);
724
+ } finally {
725
+ dispose();
726
+ viteCreationResults = null;
727
+ }
728
+ }
729
+
664
730
  async function runGracefulShutdown({
665
731
  logger,
666
732
  serverInitiated,
@@ -688,13 +754,7 @@ export async function createDevServer({
688
754
  });
689
755
  // Cleanup AI service cache
690
756
  await aiService?.removeIntegrationCache();
691
- // Cleanup the vite server
692
- if (viteCreationResults) {
693
- await viteCreationResults.viteServer?.close();
694
- viteCreationResults.dispose();
695
- viteCreationResults = null;
696
- isViteServerInitialized = false;
697
- }
757
+ await closeViteServer();
698
758
  } catch (e) {
699
759
  logger.error(`Error during graceful shutdown: ${e}`);
700
760
  } finally {
@@ -960,28 +1020,13 @@ export async function createDevServer({
960
1020
  if (isRestart) {
961
1021
  logger.info("Pausing dev server while restarting");
962
1022
  lockService?.setIsRestarting();
963
- syncService?.cancelPendingSync();
964
1023
  // Invalidate chat session cache to reload fresh chat messages after checkpoint restore
965
1024
  aiService?.chatSessionStore.invalidateCache();
966
- if (viteCreationResults) {
967
- const index = viteCreationResults.routeIndex;
968
- if (index !== -1) {
969
- // Remove the vite server middleware from the express app - this is a workaround to avoid
970
- // the vite server middleware being called after the dev server is closed
971
- (app._router as IRouter).stack.splice(index, 1);
972
- }
973
-
974
- await viteCreationResults.viteServer?.close();
975
- viteCreationResults.dispose();
976
- viteCreationResults = null;
977
- vitePromise = new Promise((resolve, reject) => {
978
- viteResolve = resolve;
979
- viteReject = reject;
980
- });
981
- vitePromise.catch(() => {});
982
- isViteServerInitialized = false;
983
- }
1025
+ await closeViteServer();
984
1026
  res.send("ok");
1027
+ // RESTART_EXIT_CODE tells the supervisor to respawn, and this runs
1028
+ // synchronously below (no await), so the next /_sb_connect is served by
1029
+ // the fresh process. There is nothing to re-arm in this dying one.
985
1030
  process.exit(RESTART_EXIT_CODE);
986
1031
  } else {
987
1032
  logger.info("Shutting down dev server");
@@ -1159,6 +1204,26 @@ export async function createDevServer({
1159
1204
  if (fatalExitHandled) {
1160
1205
  return;
1161
1206
  }
1207
+ // A connection or server we depend on closing mid-flight (socket code 8/10,
1208
+ // or Vite's ERR_CLOSED_SERVER from a call racing an in-place restart) is
1209
+ // expected and recoverable, so it must not kill the pod. See
1210
+ // isRecoverableShutdownError for the shapes and why their owning paths
1211
+ // already treat them as non-fatal.
1212
+ if (isRecoverableShutdownError(error)) {
1213
+ logger.warn(
1214
+ "Ignoring recoverable shutdown error (uncaughtException)",
1215
+ getErrorMeta(error),
1216
+ );
1217
+ // Keep an alertable signal. Suppressing the fatal exit drops the
1218
+ // dev_server_fatal_exit log token, so this counter is the only
1219
+ // dashboard-queryable way to see these (and catch a regression in their
1220
+ // rate) without a log search.
1221
+ devServerMetrics.recordRecoverableShutdownError(
1222
+ "uncaughtException",
1223
+ error,
1224
+ );
1225
+ return;
1226
+ }
1162
1227
  fatalExitHandled = true;
1163
1228
  // Write the fatal-exit line first, and synchronously, so the reason reaches
1164
1229
  // the logs before anything else runs. Shutdown below may finish first and
@@ -1193,6 +1258,22 @@ export async function createDevServer({
1193
1258
  if (fatalExitHandled) {
1194
1259
  return;
1195
1260
  }
1261
+ // See the uncaughtException handler above: a connection/server close racing
1262
+ // an in-place restart (socket code 8/10 or Vite's ERR_CLOSED_SERVER) is
1263
+ // recoverable and must not wedge the pod (APPS-4797).
1264
+ if (isRecoverableShutdownError(reason)) {
1265
+ logger.warn(
1266
+ "Ignoring recoverable shutdown rejection (unhandledRejection)",
1267
+ getErrorMeta(reason),
1268
+ );
1269
+ // Alertable signal in place of the suppressed dev_server_fatal_exit log
1270
+ // token; see the uncaughtException handler above.
1271
+ devServerMetrics.recordRecoverableShutdownError(
1272
+ "unhandledRejection",
1273
+ reason,
1274
+ );
1275
+ return;
1276
+ }
1196
1277
  fatalExitHandled = true;
1197
1278
  logger.error(
1198
1279
  buildFatalExitLog({ handler: "unhandledRejection", exitCode: 1, isWarm }),
@@ -111,3 +111,54 @@ export function buildFatalExitLog(fields: FatalExitFields): string {
111
111
  `is_warm=${fields.isWarm}`,
112
112
  ].join(" ");
113
113
  }
114
+
115
+ /**
116
+ * Error `code`s that mean "a connection or server we depend on was torn down
117
+ * while a request was in flight", not "the process is broken". They are
118
+ * expected during an in-place dev-server restart / reconnect and are
119
+ * recoverable, so the process-level fatal handler must not kill the pod for
120
+ * them. Two layers throw two different shapes, both benign here:
121
+ *
122
+ * 8 `packages/shared/src/socket/socket.ts` rejects every
123
+ * pending request with `{ code: 8, message: '[internal]
124
+ * Connection closed' }` when the server WebSocket closes
125
+ * unexpectedly (server-side teardown, abnormal 1006).
126
+ * 10 a socket `request()` issued after the close handshake.
127
+ * "ERR_CLOSED_SERVER" Vite throws this from `transformRequest` / module-graph
128
+ * work that lands after `ViteDevServer.close()`. Our
129
+ * in-place restart closes Vite, so an in-flight
130
+ * file-sync/HMR call can race the close. Note the string
131
+ * code: it comes from Vite, not from our socket layer.
132
+ *
133
+ * The editor/library socket path and Vite's own request path already treat
134
+ * these as expected; the fire-and-forget callers that reach the process-level
135
+ * handler do not, which is why this predicate exists (APPS-4797 / APPS-4667).
136
+ *
137
+ * Load-bearing assumption: this is a purely structural `code` match, so it
138
+ * trusts that the numeric codes 8 and 10 are produced ONLY by our socket layer
139
+ * (`packages/shared/src/socket/socket.ts`) and are not reused by another source
140
+ * for an unrelated, genuinely-fatal error. libuv/Node system error codes are
141
+ * negative or string (`EPIPE`, `-32`), so they never collide. gRPC status codes
142
+ * DO collide numerically (8 = RESOURCE_EXHAUSTED, 10 = ABORTED), but this
143
+ * process runs only the socket client and Vite, with no gRPC rejection path
144
+ * reaching the process-level handler, so the match is safe today. If a gRPC
145
+ * client is ever added here, gate the numeric codes on a discriminating field
146
+ * (e.g. the `'[internal]'` message prefix) instead of widening the set. The
147
+ * string `ERR_CLOSED_SERVER` is already Vite-specific.
148
+ */
149
+ const RECOVERABLE_SHUTDOWN_CODES = new Set<number | string>([
150
+ 8,
151
+ 10,
152
+ "ERR_CLOSED_SERVER",
153
+ ]);
154
+
155
+ export function isRecoverableShutdownError(reason: unknown): boolean {
156
+ if (typeof reason !== "object" || reason === null || !("code" in reason)) {
157
+ return false;
158
+ }
159
+ const { code } = reason as { code: unknown };
160
+ if (typeof code !== "number" && typeof code !== "string") {
161
+ return false;
162
+ }
163
+ return RECOVERABLE_SHUTDOWN_CODES.has(code);
164
+ }
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  buildFatalExitLog,
5
5
  FATAL_EXIT_EVENT,
6
+ isRecoverableShutdownError,
6
7
  parseIsWarm,
7
8
  } from "./fatal-exit.mjs";
8
9
 
@@ -79,3 +80,50 @@ describe("buildFatalExitLog", () => {
79
80
  );
80
81
  });
81
82
  });
83
+
84
+ describe("isRecoverableShutdownError", () => {
85
+ it("matches the connection-closed SocketError codes (8 and 10)", () => {
86
+ // The exact object socket.ts rejects pending requests with on an abnormal
87
+ // close, plus the SocketErrorException shape for a request-after-close.
88
+ expect(
89
+ isRecoverableShutdownError({
90
+ code: 8,
91
+ message: "[internal] Connection closed",
92
+ }),
93
+ ).toBe(true);
94
+ expect(
95
+ isRecoverableShutdownError({
96
+ code: 10,
97
+ message: "[internal] Connection closed",
98
+ }),
99
+ ).toBe(true);
100
+ });
101
+
102
+ it("matches Vite's ERR_CLOSED_SERVER (a string code, raced against close)", () => {
103
+ expect(
104
+ isRecoverableShutdownError({
105
+ code: "ERR_CLOSED_SERVER",
106
+ message: "The server is being restarted or closed.",
107
+ }),
108
+ ).toBe(true);
109
+ });
110
+
111
+ it("does not match other socket error codes (those are genuine faults)", () => {
112
+ // 2 = unknown method, 3 = handler exception, 7 = no-response timeout.
113
+ for (const code of [2, 3, 7]) {
114
+ expect(isRecoverableShutdownError({ code, message: "boom" })).toBe(false);
115
+ }
116
+ });
117
+
118
+ it("does not match plain errors or non-error shapes", () => {
119
+ expect(isRecoverableShutdownError(new Error("boom"))).toBe(false);
120
+ expect(isRecoverableShutdownError(undefined)).toBe(false);
121
+ expect(isRecoverableShutdownError(null)).toBe(false);
122
+ expect(isRecoverableShutdownError("Connection closed")).toBe(false);
123
+ expect(isRecoverableShutdownError({ message: "no code" })).toBe(false);
124
+ // A numeric code as a string ("8") is not our socket code, and an unrelated
125
+ // string code is not Vite's — neither is recoverable.
126
+ expect(isRecoverableShutdownError({ code: "8" })).toBe(false);
127
+ expect(isRecoverableShutdownError({ code: "ERR_OTHER" })).toBe(false);
128
+ });
129
+ });