@superblocksteam/sdk 2.0.119 → 2.0.120-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.
Files changed (85) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/dev-s3-restore.test.mjs +116 -0
  3. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  4. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +1 -0
  5. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  6. package/dist/cli-replacement/dev.d.mts +6 -0
  7. package/dist/cli-replacement/dev.d.mts.map +1 -1
  8. package/dist/cli-replacement/dev.mjs +77 -6
  9. package/dist/cli-replacement/dev.mjs.map +1 -1
  10. package/dist/client.d.ts +67 -6
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +94 -0
  13. package/dist/client.js.map +1 -1
  14. package/dist/dev-utils/dedupe-async.d.ts +16 -0
  15. package/dist/dev-utils/dedupe-async.d.ts.map +1 -0
  16. package/dist/dev-utils/dedupe-async.js +27 -0
  17. package/dist/dev-utils/dedupe-async.js.map +1 -0
  18. package/dist/dev-utils/dedupe-async.test.d.ts +2 -0
  19. package/dist/dev-utils/dedupe-async.test.d.ts.map +1 -0
  20. package/dist/dev-utils/dedupe-async.test.js +120 -0
  21. package/dist/dev-utils/dedupe-async.test.js.map +1 -0
  22. package/dist/dev-utils/dev-server-metrics.d.mts +95 -0
  23. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -0
  24. package/dist/dev-utils/dev-server-metrics.mjs +193 -0
  25. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -0
  26. package/dist/dev-utils/dev-server-persist.test.mjs +117 -17
  27. package/dist/dev-utils/dev-server-persist.test.mjs.map +1 -1
  28. package/dist/dev-utils/dev-server.d.mts +19 -1
  29. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  30. package/dist/dev-utils/dev-server.mjs +296 -28
  31. package/dist/dev-utils/dev-server.mjs.map +1 -1
  32. package/dist/flag.d.ts +1 -1
  33. package/dist/flag.d.ts.map +1 -1
  34. package/dist/flag.js +3 -3
  35. package/dist/flag.js.map +1 -1
  36. package/dist/index.d.ts +4 -3
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +3 -2
  39. package/dist/index.js.map +1 -1
  40. package/dist/sdk.d.ts +8 -1
  41. package/dist/sdk.d.ts.map +1 -1
  42. package/dist/sdk.js +24 -1
  43. package/dist/sdk.js.map +1 -1
  44. package/dist/sdk.test.js +102 -1
  45. package/dist/sdk.test.js.map +1 -1
  46. package/dist/telemetry/index.d.ts +2 -1
  47. package/dist/telemetry/index.d.ts.map +1 -1
  48. package/dist/telemetry/index.js +8 -1
  49. package/dist/telemetry/index.js.map +1 -1
  50. package/dist/telemetry/logging.d.ts +1 -2
  51. package/dist/telemetry/logging.d.ts.map +1 -1
  52. package/dist/telemetry/logging.js +1 -1
  53. package/dist/telemetry/logging.js.map +1 -1
  54. package/dist/telemetry/memory-metrics.d.ts +3 -0
  55. package/dist/telemetry/memory-metrics.d.ts.map +1 -0
  56. package/dist/telemetry/memory-metrics.js +59 -0
  57. package/dist/telemetry/memory-metrics.js.map +1 -0
  58. package/dist/types/common.d.ts +1 -1
  59. package/dist/types/common.d.ts.map +1 -1
  60. package/dist/version-control.d.mts.map +1 -1
  61. package/dist/version-control.mjs +14 -19
  62. package/dist/version-control.mjs.map +1 -1
  63. package/eslint.config.js +6 -0
  64. package/package.json +8 -8
  65. package/src/cli-replacement/dev-s3-restore.test.mts +156 -0
  66. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +1 -0
  67. package/src/cli-replacement/dev.mts +104 -14
  68. package/src/client.ts +189 -6
  69. package/src/dev-utils/dedupe-async.test.ts +151 -0
  70. package/src/dev-utils/dedupe-async.ts +45 -0
  71. package/src/dev-utils/dev-server-metrics.mts +252 -0
  72. package/src/dev-utils/dev-server-persist.test.mts +170 -19
  73. package/src/dev-utils/dev-server.mts +366 -32
  74. package/src/flag.ts +4 -4
  75. package/src/index.ts +19 -1
  76. package/src/sdk.test.ts +145 -6
  77. package/src/sdk.ts +36 -0
  78. package/src/telemetry/index.ts +9 -1
  79. package/src/telemetry/logging.ts +2 -2
  80. package/src/telemetry/memory-metrics.ts +90 -0
  81. package/src/types/common.ts +1 -1
  82. package/src/version-control.mts +11 -30
  83. package/test/version-control.test.mts +0 -2
  84. package/tsconfig.tsbuildinfo +1 -1
  85. package/turbo.json +1 -0
@@ -26,6 +26,7 @@ import {
26
26
  AiServiceFeatureFlags,
27
27
  SnapshotManager,
28
28
  isSdkApiTemplate,
29
+ stripResolvedFromLockfile,
29
30
  } from "@superblocksteam/vite-plugin-file-sync/ai-service";
30
31
  import type { DraftInterface } from "@superblocksteam/vite-plugin-file-sync/draft-interface";
31
32
  import { createGitService } from "@superblocksteam/vite-plugin-file-sync/git-service";
@@ -295,7 +296,7 @@ async function installPackages(cwd: string, logger: Logger) {
295
296
  logger.info("Package installation completed successfully");
296
297
  logger.info(stdout);
297
298
  } catch (error) {
298
- logger.error(`Error during package installation: ${error}`);
299
+ logger.error("Error during package installation", getErrorMeta(error));
299
300
  throw error;
300
301
  }
301
302
  }
@@ -351,6 +352,13 @@ export async function dev(options: {
351
352
  /** Pre-existing HTTP server from warm standby mode (avoids port gap on transition). */
352
353
  existingServer?: HttpServer;
353
354
 
355
+ /**
356
+ * Wall-clock timestamp (Date.now()) when the warm-standby /_sb_activate
357
+ * POST was accepted. Forwarded to createDevServer so the warm-activation
358
+ * handoff histogram can be recorded.
359
+ */
360
+ warmActivationStart?: number;
361
+
354
362
  /** Force package installation when the caller has detected dependency drift. */
355
363
  forcePackageInstall?: boolean;
356
364
 
@@ -383,8 +391,26 @@ export async function dev(options: {
383
391
  let snapshotManager: SnapshotManager | undefined;
384
392
  let gitUserName: string | undefined;
385
393
  let gitUserEmail: string | undefined;
394
+ // In-flight install handle. We launch the install while the upload/restart
395
+ // decisions are still being evaluated and join at every gate that depends
396
+ // on post-install state. See the install block and `joinPackageInstall`.
397
+ let packageInstallPromise: Promise<void> | undefined;
386
398
  const tracer = getTracer();
387
399
  const logger = getLogger(options.logger);
400
+ // Joins the in-flight install at any step that depends on a settled
401
+ // node_modules / lockfile state. Clears the handle so later joins are
402
+ // no-ops. The rejection (if any) propagates so the caller's step can
403
+ // abort cleanly. Defined at outer scope so the post-sync `createDevServer`
404
+ // gate and the abort handler can call it too.
405
+ const joinPackageInstall = async (reason: string): Promise<void> => {
406
+ if (!packageInstallPromise) {
407
+ return;
408
+ }
409
+ logger.info(`Waiting for background package install (${reason})…`);
410
+ const promise = packageInstallPromise;
411
+ packageInstallPromise = undefined;
412
+ await promise;
413
+ };
388
414
  const skipAutoUpgrade = autoUpgradeMode === DevServerAutoUpgradeMode.SKIP;
389
415
  const skipCliUpgrade =
390
416
  skipAutoUpgrade ||
@@ -804,6 +830,16 @@ export async function dev(options: {
804
830
  logger.info("[dev-startup] Skipping download, already in sync");
805
831
  }
806
832
 
833
+ // Unconditional lockfile sanitation: strip cross-registry
834
+ // `resolved` URLs from any lockfile on disk regardless of whether
835
+ // install runs next. The lockfile here is whatever survived the
836
+ // DBFS path (downloadFirst overwrite, prior boot, brownfield
837
+ // import); npm honors `resolved` verbatim and would bypass the
838
+ // active registry. `integrity` is preserved, so genuine
839
+ // cross-registry tarball drift surfaces as EINTEGRITY. No-op when
840
+ // there's no lockfile or no `resolved` entries.
841
+ await stripResolvedFromLockfile(cwd);
842
+
807
843
  let hasCliUpdated = false;
808
844
  let upgradePromises: Promise<void>[] = [];
809
845
  const forceUpgrade =
@@ -917,17 +953,42 @@ export async function dev(options: {
917
953
  upgradePromises.length > 0 ||
918
954
  forcePackageInstall
919
955
  ) {
920
- logger.info("Installing packages…");
921
- await tracer.startActiveSpan("installPackages", async (span) => {
922
- try {
923
- // Upgrade global CLI and local packages in parallel - improves performance
924
- await Promise.all([
925
- ...upgradePromises,
926
- installPackages(cwd, logger),
927
- ]);
928
- } finally {
929
- span.end();
930
- }
956
+ // Launch the install while the upload/restart decisions below
957
+ // are still being evaluated. The synchronous joins at upload,
958
+ // CLI restart, and pre-Vite-startup all observe and surface
959
+ // any rejection; the `.catch` backstop only fires if every
960
+ // join was skipped (rare — would mean no upload, no restart,
961
+ // and a re-throw before reaching createDevServer).
962
+ logger.info("Starting package install in background…");
963
+ packageInstallPromise = tracer.startActiveSpan(
964
+ "installPackages",
965
+ async (span) => {
966
+ try {
967
+ // Upgrade global CLI and local packages in parallel - improves performance
968
+ await Promise.all([
969
+ ...upgradePromises,
970
+ installPackages(cwd, logger),
971
+ ]);
972
+ } finally {
973
+ span.end();
974
+ }
975
+ },
976
+ );
977
+ // Backstop: assigning `.catch` to a separate (discarded) promise
978
+ // keeps `packageInstallPromise` itself rejecting, so the joins
979
+ // can still observe and abort. Without this, an install that
980
+ // fails before any join fires becomes an unhandled rejection.
981
+ const installApplicationId = applicationConfig.id;
982
+ const installUpgradeCount = upgradePromises.length;
983
+ packageInstallPromise.catch((err) => {
984
+ logger.error(
985
+ // errorId is encoded into the message body because the
986
+ // logger.error contract limits structured attributes to
987
+ // `{ error: { kind, message, stack } }`. The id stays
988
+ // grep-able for Datadog/Sentry alert rules.
989
+ `Background package install failed [errorId=DEV_SERVER_BG_INSTALL_FAILED applicationId=${installApplicationId} cwd=${cwd} upgradePromiseCount=${installUpgradeCount}]`,
990
+ getErrorMeta(err),
991
+ );
931
992
  });
932
993
  } else {
933
994
  logger.info(
@@ -938,6 +999,9 @@ export async function dev(options: {
938
999
  const shouldUploadPackageState =
939
1000
  hasPackageChanged || forcePackageInstall;
940
1001
  if (shouldUploadPackageState || uploadFirst) {
1002
+ // Upload serializes the post-install lockfile + node_modules
1003
+ // tree to DBFS, so it must observe a quiesced install.
1004
+ await joinPackageInstall("before upload");
941
1005
  logger.info(
942
1006
  `Uploading local files to branch '${activeDbfsBranchName}' on server before starting`,
943
1007
  );
@@ -952,6 +1016,9 @@ export async function dev(options: {
952
1016
  }
953
1017
 
954
1018
  if (hasCliUpdated) {
1019
+ // Exiting mid-install would leave a half-written lockfile that
1020
+ // the next boot would have to recover from.
1021
+ await joinPackageInstall("before CLI restart");
955
1022
  try {
956
1023
  logger.info("Releasing lock before restarting the dev server");
957
1024
  await aiService?.removeIntegrationCache();
@@ -967,9 +1034,9 @@ export async function dev(options: {
967
1034
  }
968
1035
  });
969
1036
  } catch (error: any) {
970
- const msg = error instanceof Error ? error.message : String(error);
971
1037
  logger.error(
972
- `[dev-server] Startup failed during sync/lock/setup (exiting with code 1): ${msg}`,
1038
+ "[dev-server] Startup failed during sync/lock/setup (exiting with code 1)",
1039
+ getErrorMeta(error),
973
1040
  );
974
1041
  try {
975
1042
  await aiService?.removeIntegrationCache();
@@ -983,6 +1050,15 @@ export async function dev(options: {
983
1050
  logger.info("Skipping directory sync");
984
1051
  }
985
1052
 
1053
+ // Drain the install before Vite starts. Vite's createServer eagerly
1054
+ // scans `node_modules` for `optimizeDeps` pre-bundling; if that scan
1055
+ // observes a mid-install state (npm moves per-package directories via
1056
+ // rename, so a reader can race the rename), the resulting `.vite/deps`
1057
+ // cache embeds partial state that survives across reloads. Awaiting
1058
+ // here costs at most the install's remaining wall time — the upload
1059
+ // and CLI-restart joins above usually drain it first.
1060
+ await joinPackageInstall("before Vite startup");
1061
+
986
1062
  const activateRuntimeGitService = async (): Promise<
987
1063
  GitService | undefined
988
1064
  > => {
@@ -1098,6 +1174,7 @@ export async function dev(options: {
1098
1174
  sdk,
1099
1175
  superblocksBaseUrl: tokenConfig.superblocksBaseUrl,
1100
1176
  existingServer: options.existingServer,
1177
+ warmActivationStart: options.warmActivationStart,
1101
1178
  // TODO: Remove this cast — build the options object to match
1102
1179
  // CreateDevServerOptions directly so new required fields cause a
1103
1180
  // compile error instead of silently passing undefined.
@@ -1117,6 +1194,19 @@ export async function dev(options: {
1117
1194
  logger.warn(`Error stopping auth hot-reload server: ${error}`);
1118
1195
  });
1119
1196
  }
1197
+ // Drain any straggler install before tear-down. By this point the
1198
+ // pre-Vite join has already run on the success path, so usually
1199
+ // `packageInstallPromise` is undefined and this is a no-op; if abort
1200
+ // races createDevServer (or fires during the inner sync block on
1201
+ // some error path), draining here keeps the spawned npm child from
1202
+ // being SIGKILL'd mid-rename. Errors only get logged — we are tearing
1203
+ // down anyway.
1204
+ joinPackageInstall("during abort").catch((error: unknown) => {
1205
+ logger.warn(
1206
+ "Error draining background install during abort",
1207
+ getErrorMeta(error),
1208
+ );
1209
+ });
1120
1210
  // Clean up AI service cache (must happen before app switches directories)
1121
1211
  aiService?.removeIntegrationCache().catch((error: any) => {
1122
1212
  logger.warn(`Error removing integration cache: ${error}`);
package/src/client.ts CHANGED
@@ -2796,21 +2796,71 @@ export interface UsageRecordRow {
2796
2796
  source: string;
2797
2797
  }
2798
2798
 
2799
+ export interface UserCreditLimit {
2800
+ amountLimitCents: number | null;
2801
+ creditLimit: number | null;
2802
+ id: string;
2803
+ orgId: string;
2804
+ updatedAt: string;
2805
+ updatedBy: string | null;
2806
+ userId: string | null;
2807
+ }
2808
+
2809
+ export type OrgCreditLimit = Omit<UserCreditLimit, "userId">;
2810
+
2811
+ export interface BillingUsageLimitsResponse {
2812
+ limits: UserCreditLimit[];
2813
+ orgLimit: OrgCreditLimit | null;
2814
+ }
2815
+
2816
+ type BillingUsageLimitValue =
2817
+ | {
2818
+ amountLimitCents: number;
2819
+ creditLimit?: never;
2820
+ }
2821
+ | {
2822
+ amountLimitCents?: never;
2823
+ creditLimit: number;
2824
+ };
2825
+
2826
+ export type UpdateUserBillingUsageLimitRequest = BillingUsageLimitValue & {
2827
+ userId: string | null;
2828
+ };
2829
+
2830
+ export type UpdateOrgBillingUsageLimitRequest = BillingUsageLimitValue;
2831
+
2799
2832
  export interface PlanSummary {
2800
2833
  billingInterval: "annual" | "monthly";
2834
+ builderSeatsAssigned?: number;
2835
+ builderSeatsTotal?: number;
2836
+ contractEnd?: string | null;
2837
+ contractStart?: string | null;
2838
+ contractType?: string | null;
2801
2839
  currentPlanCredits: number;
2802
2840
  currentUsageCredits: number;
2803
2841
  cycleStart: string | null;
2804
2842
  cycleEnd: string | null;
2805
2843
  creditsPerSeat: number | null;
2806
- pricePerSeatAnnual: number | null;
2807
- pricePerSeatMonthly: number | null;
2808
- deployedAppsUsed: number;
2809
- deployedAppsLimit: number;
2810
- deployedAppsIncluded: number;
2811
2844
  deployedAppsAdditional: number;
2812
- deployedAppPriceMonthly: number | null;
2813
2845
  deployedAppPriceAnnual: number | null;
2846
+ deployedAppPriceMonthly: number | null;
2847
+ deployedAppsIncluded: number;
2848
+ deployedAppsLimit: number;
2849
+ deployedAppsUsed: number;
2850
+ deployedAppCostCents?: number | null;
2851
+ dollarCommitAmountCents?: number | null;
2852
+ dollarCommitUsedCents?: number | null;
2853
+ overageAppsEnabled?: boolean;
2854
+ overageCreditsEnabled?: boolean;
2855
+ overageSeatsEnabled?: boolean;
2856
+ pricePerSeatAnnual: number | null;
2857
+ pricePerSeatMonthly: number | null;
2858
+ seatsUnlimited?: boolean;
2859
+ subscriptionStatus: string | null;
2860
+ topupCreditsPurchased: number;
2861
+ topupCreditsUsed: number;
2862
+ usageResetPeriodLimit: number;
2863
+ usageResetPeriodType: "annual" | "contract_term" | "monthly";
2814
2864
  }
2815
2865
 
2816
2866
  export async function fetchBillingUsageDaily({
@@ -2945,6 +2995,139 @@ export async function fetchBillingPlanSummary({
2945
2995
  }
2946
2996
  }
2947
2997
 
2998
+ export async function fetchBillingUsageLimits({
2999
+ cliVersion,
3000
+ token,
3001
+ superblocksBaseUrl,
3002
+ }: {
3003
+ cliVersion: string;
3004
+ token: string;
3005
+ superblocksBaseUrl: string;
3006
+ }): Promise<BillingUsageLimitsResponse> {
3007
+ try {
3008
+ const url = new URL(
3009
+ `${BASE_SERVER_API_URL_V1}/billing/user-credit-limits`,
3010
+ superblocksBaseUrl,
3011
+ );
3012
+
3013
+ const config: AxiosRequestConfig = {
3014
+ method: "get",
3015
+ url: url.toString(),
3016
+ headers: {
3017
+ Authorization: "Bearer " + token,
3018
+ [CLI_VERSION_HEADER]: cliVersion,
3019
+ },
3020
+ };
3021
+ const response = await axios(config);
3022
+ return response.data.data as BillingUsageLimitsResponse;
3023
+ } catch (e: any) {
3024
+ let message: string;
3025
+ if (e instanceof AxiosError) {
3026
+ message =
3027
+ (e.response?.data?.responseMeta?.message as string) ??
3028
+ JSON.stringify(e.response?.data) ??
3029
+ e.response?.statusText ??
3030
+ e?.message;
3031
+ } else {
3032
+ message = `${e?.message ? e?.message : e}`;
3033
+ }
3034
+ throw new Error(`Could not fetch billing usage limits: ${message}`);
3035
+ }
3036
+ }
3037
+
3038
+ export async function updateUserBillingUsageLimit({
3039
+ amountLimitCents,
3040
+ cliVersion,
3041
+ creditLimit,
3042
+ token,
3043
+ superblocksBaseUrl,
3044
+ userId,
3045
+ }: UpdateUserBillingUsageLimitRequest & {
3046
+ cliVersion: string;
3047
+ token: string;
3048
+ superblocksBaseUrl: string;
3049
+ }): Promise<{ success: boolean }> {
3050
+ try {
3051
+ const url = new URL(
3052
+ `${BASE_SERVER_API_URL_V1}/billing/user-credit-limits`,
3053
+ superblocksBaseUrl,
3054
+ );
3055
+
3056
+ const config: AxiosRequestConfig = {
3057
+ method: "put",
3058
+ url: url.toString(),
3059
+ headers: {
3060
+ Authorization: "Bearer " + token,
3061
+ [CLI_VERSION_HEADER]: cliVersion,
3062
+ },
3063
+ data: {
3064
+ userId,
3065
+ ...(amountLimitCents !== undefined
3066
+ ? { amountLimitCents }
3067
+ : { creditLimit }),
3068
+ },
3069
+ };
3070
+ const response = await axios(config);
3071
+ return response.data.data as { success: boolean };
3072
+ } catch (e: any) {
3073
+ let message: string;
3074
+ if (e instanceof AxiosError) {
3075
+ message =
3076
+ (e.response?.data?.responseMeta?.message as string) ??
3077
+ JSON.stringify(e.response?.data) ??
3078
+ e.response?.statusText ??
3079
+ e?.message;
3080
+ } else {
3081
+ message = `${e?.message ? e?.message : e}`;
3082
+ }
3083
+ throw new Error(`Could not update user billing usage limit: ${message}`);
3084
+ }
3085
+ }
3086
+
3087
+ export async function updateOrgBillingUsageLimit({
3088
+ amountLimitCents,
3089
+ cliVersion,
3090
+ creditLimit,
3091
+ token,
3092
+ superblocksBaseUrl,
3093
+ }: UpdateOrgBillingUsageLimitRequest & {
3094
+ cliVersion: string;
3095
+ token: string;
3096
+ superblocksBaseUrl: string;
3097
+ }): Promise<{ success: boolean }> {
3098
+ try {
3099
+ const url = new URL(
3100
+ `${BASE_SERVER_API_URL_V1}/billing/user-credit-limits/org`,
3101
+ superblocksBaseUrl,
3102
+ );
3103
+
3104
+ const config: AxiosRequestConfig = {
3105
+ method: "put",
3106
+ url: url.toString(),
3107
+ headers: {
3108
+ Authorization: "Bearer " + token,
3109
+ [CLI_VERSION_HEADER]: cliVersion,
3110
+ },
3111
+ data:
3112
+ amountLimitCents !== undefined ? { amountLimitCents } : { creditLimit },
3113
+ };
3114
+ const response = await axios(config);
3115
+ return response.data.data as { success: boolean };
3116
+ } catch (e: any) {
3117
+ let message: string;
3118
+ if (e instanceof AxiosError) {
3119
+ message =
3120
+ (e.response?.data?.responseMeta?.message as string) ??
3121
+ JSON.stringify(e.response?.data) ??
3122
+ e.response?.statusText ??
3123
+ e?.message;
3124
+ } else {
3125
+ message = `${e?.message ? e?.message : e}`;
3126
+ }
3127
+ throw new Error(`Could not update org billing usage limit: ${message}`);
3128
+ }
3129
+ }
3130
+
2948
3131
  // ---------------------------------------------------------------------------
2949
3132
  // Knowledge (Facts)
2950
3133
  // ---------------------------------------------------------------------------
@@ -0,0 +1,151 @@
1
+ // Regression tests for the in-flight dedupe used by the dev-server graceful
2
+ // shutdown path (hq-o55a). The bug being guarded against: multiple signals
3
+ // (SIGINT, SIGTERM, SIGABRT, uncaughtException) fire in rapid succession during
4
+ // shutdown and each handler re-runs cleanup against already-freed native state,
5
+ // surfacing as `double free or corruption (fasttop)` from glibc.
6
+
7
+ import { describe, expect, it, vi } from "vitest";
8
+
9
+ import { dedupeAsync } from "./dedupe-async.js";
10
+
11
+ describe("dedupeAsync", () => {
12
+ it("invokes the wrapped function exactly once across concurrent calls", async () => {
13
+ const inner = vi.fn(async () => "ok");
14
+ const wrapped = dedupeAsync(inner);
15
+
16
+ const results = await Promise.all([
17
+ wrapped(),
18
+ wrapped(),
19
+ wrapped(),
20
+ wrapped(),
21
+ ]);
22
+
23
+ expect(inner).toHaveBeenCalledTimes(1);
24
+ expect(results).toEqual(["ok", "ok", "ok", "ok"]);
25
+ });
26
+
27
+ it("returns the same promise instance to all concurrent callers", () => {
28
+ const inner = vi.fn(async () => 42);
29
+ const wrapped = dedupeAsync(inner);
30
+
31
+ const p1 = wrapped();
32
+ const p2 = wrapped();
33
+
34
+ expect(p1).toBe(p2);
35
+ });
36
+
37
+ it("invokes onDuplicate for each call after the first", async () => {
38
+ let resolveInner: (v: string) => void = () => {};
39
+ const inner = vi.fn(
40
+ () =>
41
+ new Promise<string>((resolve) => {
42
+ resolveInner = resolve;
43
+ }),
44
+ );
45
+ const onDuplicate = vi.fn();
46
+ const wrapped = dedupeAsync(inner, { onDuplicate });
47
+
48
+ const p1 = wrapped();
49
+ wrapped();
50
+ wrapped();
51
+ wrapped();
52
+
53
+ expect(onDuplicate).toHaveBeenCalledTimes(3);
54
+ expect(inner).toHaveBeenCalledTimes(1);
55
+
56
+ resolveInner("done");
57
+ await expect(p1).resolves.toBe("done");
58
+ });
59
+
60
+ it("retains the cached promise after the inner call settles — post-settle callers get the same outcome", async () => {
61
+ const inner = vi.fn(async () => "shutdown-complete");
62
+ const wrapped = dedupeAsync(inner);
63
+
64
+ await expect(wrapped()).resolves.toBe("shutdown-complete");
65
+ // Second invocation AFTER the first settled — must NOT re-trigger.
66
+ await expect(wrapped()).resolves.toBe("shutdown-complete");
67
+ expect(inner).toHaveBeenCalledTimes(1);
68
+ });
69
+
70
+ it("propagates rejection to all callers without re-running the inner function", async () => {
71
+ const inner = vi.fn(async () => {
72
+ throw new Error("boom");
73
+ });
74
+ const wrapped = dedupeAsync(inner);
75
+
76
+ const p1 = wrapped();
77
+ const p2 = wrapped();
78
+ const p3 = wrapped();
79
+
80
+ await expect(p1).rejects.toThrow(/boom/);
81
+ await expect(p2).rejects.toThrow(/boom/);
82
+ await expect(p3).rejects.toThrow(/boom/);
83
+ expect(inner).toHaveBeenCalledTimes(1);
84
+ });
85
+
86
+ it("retains the cached rejection after the inner call settles — post-settle callers get the same rejection without re-run", async () => {
87
+ // Guards against a future change that resets `inFlight = null` on
88
+ // rejection (a plausible "retry on failure" tweak). The shutdown
89
+ // contract is that BOTH outcomes are sticky — resolve and reject —
90
+ // because re-entering cleanup after a failed first attempt is what
91
+ // caused the original double-free. A passing test for the resolve path
92
+ // alone would let that regression slip through.
93
+ const inner = vi.fn(async () => {
94
+ throw new Error("boom");
95
+ });
96
+ const wrapped = dedupeAsync(inner);
97
+
98
+ await expect(wrapped()).rejects.toThrow(/boom/);
99
+ // Second invocation AFTER the first rejection settled — must NOT
100
+ // re-trigger and must surface the same rejection.
101
+ await expect(wrapped()).rejects.toThrow(/boom/);
102
+ expect(inner).toHaveBeenCalledTimes(1);
103
+ });
104
+
105
+ it("forwards args to the inner function on the first call only", async () => {
106
+ const inner = vi.fn(async (a: number, b: string) => `${a}-${b}`);
107
+ const wrapped = dedupeAsync(inner);
108
+
109
+ const p1 = wrapped(1, "first");
110
+ // Second call passes different args, but is short-circuited — inner
111
+ // should NOT be invoked again. The dedupe is intentionally
112
+ // input-agnostic: shutdown semantics don't depend on the second
113
+ // signal's arguments.
114
+ const p2 = wrapped(2, "second");
115
+
116
+ await expect(p1).resolves.toBe("1-first");
117
+ await expect(p2).resolves.toBe("1-first");
118
+ expect(inner).toHaveBeenCalledTimes(1);
119
+ expect(inner).toHaveBeenCalledWith(1, "first");
120
+ });
121
+
122
+ it("passes the duplicate caller's args to onDuplicate (for source-tagged logging)", async () => {
123
+ // The dev-server graceful shutdown threads a `source` field through
124
+ // (signal name, "uncaughtException", "/_sb_disconnect") so the
125
+ // duplicate-suppression log can tell apart "4 OS signals fired" from
126
+ // "1 signal fired and cleanup panicked 3 times". The dedupe must
127
+ // forward each duplicate's own args, not the first caller's.
128
+ let resolveInner: () => void = () => {};
129
+ const inner = vi.fn(
130
+ (arg: { source: string }): Promise<{ source: string }> =>
131
+ new Promise((resolve) => {
132
+ resolveInner = () => resolve(arg);
133
+ }),
134
+ );
135
+ const onDuplicate = vi.fn();
136
+ const wrapped = dedupeAsync(inner, { onDuplicate });
137
+
138
+ const p1 = wrapped({ source: "SIGTERM" });
139
+ wrapped({ source: "SIGINT" });
140
+ wrapped({ source: "SIGABRT" });
141
+
142
+ expect(onDuplicate).toHaveBeenCalledTimes(2);
143
+ expect(onDuplicate).toHaveBeenNthCalledWith(1, { source: "SIGINT" });
144
+ expect(onDuplicate).toHaveBeenNthCalledWith(2, { source: "SIGABRT" });
145
+ expect(inner).toHaveBeenCalledTimes(1);
146
+ expect(inner).toHaveBeenCalledWith({ source: "SIGTERM" });
147
+
148
+ resolveInner();
149
+ await p1;
150
+ });
151
+ });
@@ -0,0 +1,45 @@
1
+ // In-flight call deduper. Wraps an async function so that concurrent invocations
2
+ // share the same promise — subsequent callers get the original in-flight result
3
+ // instead of triggering another execution.
4
+ //
5
+ // After the wrapped function settles (resolves OR rejects) the cached promise is
6
+ // retained so all post-settle callers receive the same outcome. There is no
7
+ // reset path: callers that want to "run again later" should use a fresh
8
+ // `dedupeAsync` instance.
9
+ //
10
+ // Used by the dev-server graceful shutdown path (hq-o55a). Multiple signals
11
+ // (SIGINT, SIGTERM, SIGABRT, uncaughtException) can fire in rapid succession
12
+ // during shutdown. Without a dedupe, every handler re-runs lockService /
13
+ // vite / httpServer cleanup against already-freed native state, which surfaces
14
+ // as `double free or corruption (fasttop)` from glibc and SIGABRT killing the
15
+ // process before it can exit cleanly.
16
+
17
+ export interface DedupeAsyncOptions<TArgs extends unknown[]> {
18
+ /**
19
+ * Optional callback invoked when a duplicate call is detected. The first
20
+ * caller's invocation runs normally; this fires for the 2nd, 3rd, …
21
+ * Useful for logging "shutdown already in progress; ignoring duplicate
22
+ * signal".
23
+ *
24
+ * Receives the *duplicate* call's args (not the first call's). The first
25
+ * call's args are what the wrapped function actually ran with — duplicates
26
+ * are intentionally short-circuited and their args are dropped from
27
+ * execution but surfaced here for logging.
28
+ */
29
+ onDuplicate?: (...args: TArgs) => void;
30
+ }
31
+
32
+ export function dedupeAsync<TArgs extends unknown[], TResult>(
33
+ fn: (...args: TArgs) => Promise<TResult>,
34
+ options: DedupeAsyncOptions<TArgs> = {},
35
+ ): (...args: TArgs) => Promise<TResult> {
36
+ let inFlight: Promise<TResult> | null = null;
37
+ return (...args: TArgs) => {
38
+ if (inFlight) {
39
+ options.onDuplicate?.(...args);
40
+ return inFlight;
41
+ }
42
+ inFlight = fn(...args);
43
+ return inFlight;
44
+ };
45
+ }