@superblocksteam/sdk 2.0.151 → 2.0.152-next.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/.turbo/turbo-build.log +1 -1
- package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
- package/dist/cli-replacement/automatic-upgrades.js +8 -6
- package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
- package/dist/cli-replacement/dev-s3-restore.test.mjs +1 -0
- package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +1 -0
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.d.mts +148 -1
- package/dist/cli-replacement/dev.d.mts.map +1 -1
- package/dist/cli-replacement/dev.interception.test.mjs +259 -4
- package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.mjs +429 -28
- package/dist/cli-replacement/dev.mjs.map +1 -1
- package/dist/client.billing-usage.test.js +25 -0
- package/dist/client.billing-usage.test.js.map +1 -1
- package/dist/client.d.ts +19 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/collect-sdk-apis.d.mts +61 -3
- package/dist/collect-sdk-apis.d.mts.map +1 -1
- package/dist/collect-sdk-apis.mjs +97 -6
- package/dist/collect-sdk-apis.mjs.map +1 -1
- package/dist/collect-sdk-apis.test.mjs +277 -1
- package/dist/collect-sdk-apis.test.mjs.map +1 -1
- package/dist/dev-utils/dev-server-metrics.d.mts +17 -0
- package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.d.mts +2 -0
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.d.mts.map +1 -0
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.mjs +108 -0
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.mjs.map +1 -0
- package/dist/dev-utils/dev-server-metrics.mjs +16 -2
- package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
- package/dist/dev-utils/dev-server.d.mts +9 -0
- package/dist/dev-utils/dev-server.d.mts.map +1 -1
- package/dist/dev-utils/dev-server.mjs +44 -27
- package/dist/dev-utils/dev-server.mjs.map +1 -1
- package/dist/dev-utils/dev-server.status.test.mjs +31 -1
- package/dist/dev-utils/dev-server.status.test.mjs.map +1 -1
- package/dist/dev-utils/fatal-exit.d.mts +17 -4
- package/dist/dev-utils/fatal-exit.d.mts.map +1 -1
- package/dist/dev-utils/fatal-exit.mjs +4 -3
- package/dist/dev-utils/fatal-exit.mjs.map +1 -1
- package/dist/extract-api-integrations.d.mts +1 -1
- package/dist/extract-api-integrations.d.mts.map +1 -1
- package/dist/extract-api-integrations.mjs +1 -1
- package/dist/extract-api-integrations.mjs.map +1 -1
- package/dist/flag.d.ts +5 -0
- package/dist/flag.d.ts.map +1 -1
- package/dist/flag.js +7 -0
- package/dist/flag.js.map +1 -1
- package/dist/flag.test.js +18 -0
- package/dist/flag.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types/common.d.ts +1 -0
- package/dist/types/common.d.ts.map +1 -1
- package/dist/types/common.js.map +1 -1
- package/dist/vite-plugin-generate-api-build-manifest.d.mts.map +1 -1
- package/dist/vite-plugin-generate-api-build-manifest.mjs +46 -0
- package/dist/vite-plugin-generate-api-build-manifest.mjs.map +1 -1
- package/package.json +6 -6
- package/src/cli-replacement/automatic-upgrades.ts +8 -6
- package/src/cli-replacement/dev-s3-restore.test.mts +1 -0
- package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +1 -0
- package/src/cli-replacement/dev.interception.test.mts +334 -10
- package/src/cli-replacement/dev.mts +499 -33
- package/src/client.billing-usage.test.ts +26 -0
- package/src/client.ts +21 -0
- package/src/collect-sdk-apis.mts +141 -5
- package/src/collect-sdk-apis.test.mts +380 -1
- package/src/dev-utils/dev-server-metrics.dependency-failure.test.mts +149 -0
- package/src/dev-utils/dev-server-metrics.mts +25 -1
- package/src/dev-utils/dev-server.mts +52 -29
- package/src/dev-utils/dev-server.status.test.mts +42 -0
- package/src/dev-utils/fatal-exit.mts +18 -4
- package/src/extract-api-integrations.mts +3 -0
- package/src/flag.test.ts +21 -0
- package/src/flag.ts +8 -0
- package/src/index.ts +1 -0
- package/src/types/common.ts +1 -0
- package/src/vite-plugin-generate-api-build-manifest.mts +74 -4
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -19,6 +19,7 @@ import { AutoConnectingRpcClient } from "@superblocksteam/vite-plugin-file-sync/
|
|
|
19
19
|
import { SyncService } from "@superblocksteam/vite-plugin-file-sync/sync-service";
|
|
20
20
|
import { devServerMetrics } from "../dev-utils/dev-server-metrics.mjs";
|
|
21
21
|
import { createDevServer } from "../dev-utils/dev-server.mjs";
|
|
22
|
+
import { buildFatalExitLog, FATAL_EXIT_EVENT, parseIsWarm, } from "../dev-utils/fatal-exit.mjs";
|
|
22
23
|
import { AUTO_UPGRADE_EXIT_CODE } from "../index.js";
|
|
23
24
|
import { getTracer, isCloudPrem, shutdownTelemetry, } from "../telemetry/index.js";
|
|
24
25
|
import { getErrorMeta, getLogger } from "../telemetry/logging.js";
|
|
@@ -695,12 +696,321 @@ export function primeTokenManagerWithInitialToken(tokenManager, token) {
|
|
|
695
696
|
tokenManager.updateToken(token);
|
|
696
697
|
}
|
|
697
698
|
}
|
|
699
|
+
/**
|
|
700
|
+
* True when this dev server was launched as a CSB sandbox — `SUPERBLOCKS_IS_CSB
|
|
701
|
+
* === "true"`, the same variable that already gates the CSB-only behavior
|
|
702
|
+
* elsewhere in this file. That is two populations, not one:
|
|
703
|
+
*
|
|
704
|
+
* - a disposable, orchestrator-managed SABS live-edit pod, and
|
|
705
|
+
* - the LOCAL `csb-mock-server` emulator, which sets the identical variable
|
|
706
|
+
* when it spawns `superblocks dev` for code-mode work
|
|
707
|
+
* (`packages/csb-mock-server/src/app-manager.ts`, `startProcess` and
|
|
708
|
+
* `restartSandbox`; neither passes anything that skips the auto-upgrade).
|
|
709
|
+
*
|
|
710
|
+
* So this is NOT "cloud, not a developer's machine". The emulator answering
|
|
711
|
+
* `true` is intended — it exists to emulate a pod, and an exit is the behavior a
|
|
712
|
+
* pod would show — but it emulates only half the story: `attachEventListeners`
|
|
713
|
+
* in that file merely logs the child's exit, so nothing restarts it. The
|
|
714
|
+
* practical consequence for a local dev whose auto-upgrade fails this way is a
|
|
715
|
+
* dead dev server until something calls `startProcess` again (reopening or
|
|
716
|
+
* restarting the app in code mode). Only a dev server the developer ran
|
|
717
|
+
* directly, with no `SUPERBLOCKS_IS_CSB`, still degrades.
|
|
718
|
+
*
|
|
719
|
+
* Read at call time, not module load, so tests can set the variable per-case.
|
|
720
|
+
*/
|
|
721
|
+
export function isDisposableSandbox() {
|
|
722
|
+
return process.env.SUPERBLOCKS_IS_CSB === "true";
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* The single implementation of "flush telemetry, but never wait forever".
|
|
726
|
+
*
|
|
727
|
+
* Two paths need it, for the same reason: the OTel metric reader exports on a
|
|
728
|
+
* 10s interval, so a sample recorded moments before the process goes away dies
|
|
729
|
+
* with it unless something forces the export — and neither path may block
|
|
730
|
+
* indefinitely on a slow or unreachable collector. The callers are the
|
|
731
|
+
* fatal-exit sequence (`flushTelemetryBeforeExit`, just below) and the
|
|
732
|
+
* auto-upgrade CLI restart in `dev()`, which used to carry its own copy of this
|
|
733
|
+
* race with the timer-clear missing.
|
|
734
|
+
*
|
|
735
|
+
* Resolves `true` only when a flush actually ran to completion — `false` on an
|
|
736
|
+
* internal shutdown error, when telemetry was never initialized, or when the
|
|
737
|
+
* timeout won the race.
|
|
738
|
+
*
|
|
739
|
+
* Reporting that boolean is left to the callers rather than parameterized here,
|
|
740
|
+
* because the useful part of the message is what an operator should read
|
|
741
|
+
* INSTEAD of the dropped samples, and that differs per path: the fatal paths
|
|
742
|
+
* point at their `event=dev_server_fatal_exit` line, which the restart path
|
|
743
|
+
* never writes.
|
|
744
|
+
*
|
|
745
|
+
* Rejects only if `shutdownTelemetry()` breaks its documented never-reject
|
|
746
|
+
* contract, so callers keep their own defensive catch.
|
|
747
|
+
*/
|
|
748
|
+
export async function flushTelemetryWithTimeout() {
|
|
749
|
+
let flushed = false;
|
|
750
|
+
let timer;
|
|
751
|
+
try {
|
|
752
|
+
await Promise.race([
|
|
753
|
+
shutdownTelemetry().then((didFlush) => {
|
|
754
|
+
flushed = didFlush;
|
|
755
|
+
}),
|
|
756
|
+
new Promise((resolve) => {
|
|
757
|
+
timer = setTimeout(resolve, TELEMETRY_FLUSH_TIMEOUT_MS);
|
|
758
|
+
}),
|
|
759
|
+
]);
|
|
760
|
+
}
|
|
761
|
+
finally {
|
|
762
|
+
// The race is settled; leaving the timer armed would hold the event loop
|
|
763
|
+
// open for callers (tests, in particular) that do not immediately exit.
|
|
764
|
+
if (timer)
|
|
765
|
+
clearTimeout(timer);
|
|
766
|
+
}
|
|
767
|
+
return flushed;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Force a telemetry export before a fatal exit.
|
|
771
|
+
*
|
|
772
|
+
* The OTel metric reader exports on a 10s interval, so anything recorded
|
|
773
|
+
* moments before `process.exit(1)` dies with the process — the same drop
|
|
774
|
+
* already documented for the auto-upgrade restart path below. Any metric whose
|
|
775
|
+
* entire purpose is to make an exit observable has to be flushed here or it is
|
|
776
|
+
* never seen. The raced flush itself lives in `flushTelemetryWithTimeout`,
|
|
777
|
+
* shared with that restart path; this wrapper only adds the fatal-exit
|
|
778
|
+
* reporting.
|
|
779
|
+
*
|
|
780
|
+
* MUST BE CALLED LAST in any shutdown sequence. This does not merely flush: it
|
|
781
|
+
* awaits `shutdownTelemetry()`, which tears the provider down, so every signal
|
|
782
|
+
* emitted after it goes into a dead pipeline. It can also block for up to
|
|
783
|
+
* `TELEMETRY_FLUSH_TIMEOUT_MS`, and delaying `lockService.shutdown()` by that
|
|
784
|
+
* much delays the lock release that lets a replacement sandbox take over —
|
|
785
|
+
* which is the entire recovery story this exit path exists to enable.
|
|
786
|
+
*
|
|
787
|
+
* "Last" is only reachable because the callers drive the lock service with
|
|
788
|
+
* `shutdown()` rather than `shutdownAndExit()`. The latter ends in an
|
|
789
|
+
* unconditional `finally { process.exit(1) }`, and `process.exit` is
|
|
790
|
+
* synchronous, so anything sequenced after it never runs at all. See the two
|
|
791
|
+
* fatal-exit blocks in `dev()`.
|
|
792
|
+
*
|
|
793
|
+
* Never throws: a stuck exporter is raced against a timeout so it cannot wedge
|
|
794
|
+
* the exit path.
|
|
795
|
+
*
|
|
796
|
+
* `shutdownTelemetry()` resolves `true` only when a flush actually ran to
|
|
797
|
+
* completion — `false` on an internal shutdown error or when telemetry was
|
|
798
|
+
* never initialized — and the timeout branch leaves the flag `false`. Log that
|
|
799
|
+
* distinction rather than discarding it: when it is `false` the `outcome="exit"`
|
|
800
|
+
* sample did NOT make it out, and the only surviving evidence of the exit is the
|
|
801
|
+
* synchronous `event=dev_server_fatal_exit` line the caller already wrote. An
|
|
802
|
+
* operator who cannot tell those apart reads "zero exit samples" as "the exit
|
|
803
|
+
* path never ran". The auto-upgrade restart path reports the same distinction,
|
|
804
|
+
* against its own log line.
|
|
805
|
+
*/
|
|
806
|
+
async function flushTelemetryBeforeExit(logger) {
|
|
807
|
+
let flushed = false;
|
|
808
|
+
try {
|
|
809
|
+
flushed = await flushTelemetryWithTimeout();
|
|
810
|
+
}
|
|
811
|
+
catch (e) {
|
|
812
|
+
// Defensive only: `shutdownTelemetry()` catches internally and resolves
|
|
813
|
+
// `false` rather than rejecting. Kept so a future change to that contract
|
|
814
|
+
// cannot turn this into an unhandled rejection on the exit path.
|
|
815
|
+
logger.warn(`[dev-server] telemetry flush before exit failed: ${e}`);
|
|
816
|
+
}
|
|
817
|
+
if (!flushed) {
|
|
818
|
+
logger.warn(`[dev-server] telemetry flush did not complete (timed out after ${TELEMETRY_FLUSH_TIMEOUT_MS}ms, errored, or telemetry disabled) before fatal exit; ` +
|
|
819
|
+
`dependency-failure samples may be dropped — use the event=${FATAL_EXIT_EVENT} log line as the source of truth for this exit`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Upper bound on any single teardown step run before a fatal exit.
|
|
824
|
+
*
|
|
825
|
+
* Each step talks to something that can stop answering (the ai-service over
|
|
826
|
+
* IPC, the lock service over the network). Without a bound, one unsettled
|
|
827
|
+
* promise means the `finally { process.exit(1) }` below it never runs and the
|
|
828
|
+
* pod stays Ready but zombied — the exact ENG-5297 symptom this exit path
|
|
829
|
+
* exists to end, one call earlier. The whole point of these paths is that the
|
|
830
|
+
* process goes away, so a step that has not answered in this long is abandoned
|
|
831
|
+
* rather than waited on.
|
|
832
|
+
*/
|
|
833
|
+
const FATAL_EXIT_STEP_TIMEOUT_MS = 5000;
|
|
834
|
+
/**
|
|
835
|
+
* Run one best-effort teardown step on a fatal-exit path.
|
|
836
|
+
*
|
|
837
|
+
* Neither a throw nor a hang may stop the later steps: the lock release is what
|
|
838
|
+
* lets a replacement sandbox take over, and the telemetry flush is what makes
|
|
839
|
+
* the exit observable, so losing them to a failure in an earlier, less
|
|
840
|
+
* important step is strictly worse than the failure itself.
|
|
841
|
+
*/
|
|
842
|
+
async function runFatalExitStep(logger, name, step) {
|
|
843
|
+
if (!step) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
let timer;
|
|
847
|
+
try {
|
|
848
|
+
await Promise.race([
|
|
849
|
+
// A synchronous throw from `step()` propagates out of the array literal
|
|
850
|
+
// before the race is constructed, and is caught by the same `catch`.
|
|
851
|
+
// `undefined` (the service was never constructed) is a non-thenable, so
|
|
852
|
+
// the race settles immediately and the step is a no-op.
|
|
853
|
+
step(),
|
|
854
|
+
new Promise((resolve) => {
|
|
855
|
+
timer = setTimeout(() => {
|
|
856
|
+
logger.error(`[dev-server] ${name} did not settle within ${FATAL_EXIT_STEP_TIMEOUT_MS}ms before fatal exit; abandoning it and continuing`);
|
|
857
|
+
resolve();
|
|
858
|
+
}, FATAL_EXIT_STEP_TIMEOUT_MS);
|
|
859
|
+
}),
|
|
860
|
+
]);
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
logger.error(`[dev-server] ${name} failed before fatal exit; continuing`, getErrorMeta(e));
|
|
864
|
+
}
|
|
865
|
+
finally {
|
|
866
|
+
if (timer)
|
|
867
|
+
clearTimeout(timer);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* The ordered teardown every fatal-exit path in `dev()` runs immediately before
|
|
872
|
+
* `process.exit(1)`. Stated once, here, so the ordering guarantee cannot drift
|
|
873
|
+
* between the two call sites.
|
|
874
|
+
*
|
|
875
|
+
* Order is load-bearing:
|
|
876
|
+
* 1. `removeIntegrationCache` — ai-service state that must not outlive us.
|
|
877
|
+
* 2. `shutdownLockService` — releases the AppBranchLock. Recovery-critical:
|
|
878
|
+
* the heartbeat dies with the process, so skipping this makes the
|
|
879
|
+
* replacement sandbox's `acquireLock()` fail with a ConflictError until
|
|
880
|
+
* the server-side TTL expires, and the replacement is the entire point of
|
|
881
|
+
* exiting.
|
|
882
|
+
* 3. `flushTelemetryBeforeExit` — LAST, because it tears the telemetry
|
|
883
|
+
* provider down (anything after it is unobservable) and can block for
|
|
884
|
+
* `TELEMETRY_FLUSH_TIMEOUT_MS`, which would otherwise delay the lock
|
|
885
|
+
* release above.
|
|
886
|
+
*
|
|
887
|
+
* Every step is individually guarded and individually bounded, so neither a
|
|
888
|
+
* throw nor a hang in an earlier step can cost a later one. Never throws.
|
|
889
|
+
*
|
|
890
|
+
* This does NOT exit. The caller owns `process.exit(1)` from a `finally`, so
|
|
891
|
+
* the exit still happens even if this function is somehow bypassed. That is
|
|
892
|
+
* also why callers must drive the lock service through `shutdown()` and not
|
|
893
|
+
* `shutdownAndExit()`: the latter ends in its own unconditional
|
|
894
|
+
* `finally { process.exit(1) }`, and `process.exit` is synchronous, so
|
|
895
|
+
* everything sequenced after it — including the flush — silently never ran.
|
|
896
|
+
*/
|
|
897
|
+
export async function runFatalExitShutdown(steps) {
|
|
898
|
+
const { logger } = steps;
|
|
899
|
+
await runFatalExitStep(logger, "ai-service integration cache removal", steps.removeIntegrationCache);
|
|
900
|
+
await runFatalExitStep(logger, "lock service shutdown", steps.shutdownLockService);
|
|
901
|
+
await flushTelemetryBeforeExit(logger);
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Upgrade-failure categories that are decided BEFORE npm can extract anything.
|
|
905
|
+
*
|
|
906
|
+
* npm builds the whole ideal tree from registry metadata before it writes a
|
|
907
|
+
* single byte into `node_modules`, so a run that died because the registry
|
|
908
|
+
* refused, could not be reached, could not be TLS-verified, or did not have the
|
|
909
|
+
* requested version never reached the extraction phase — the on-disk tree and
|
|
910
|
+
* the running esbuild service it feeds are provably untouched.
|
|
911
|
+
*
|
|
912
|
+
* These are also precisely the shape a registry/proxy outage takes. Treating
|
|
913
|
+
* them as fatal would exit every live-edit pod in the fleet simultaneously and
|
|
914
|
+
* hand SABS a queue of replacements that hit the same outage, converting a
|
|
915
|
+
* degraded-but-serving fleet into an unavailable one.
|
|
916
|
+
*
|
|
917
|
+
* Typed against `DependencyUpgradeError["category"]` so a future category added
|
|
918
|
+
* to that union has to be classified deliberately rather than drifting in.
|
|
919
|
+
*
|
|
920
|
+
* The union has exactly five members today, so holding the other four here
|
|
921
|
+
* makes `upgradeCategoryCouldTaintTree` presently equivalent to
|
|
922
|
+
* `category === "unknown"`. The set shape is kept anyway because the two forms
|
|
923
|
+
* diverge the moment a sixth category is added: an allowlist of known-safe
|
|
924
|
+
* categories fails CLOSED (a new, unclassified category exits, which is the
|
|
925
|
+
* conservative side), whereas `=== "unknown"` fails OPEN (a new category
|
|
926
|
+
* silently degrades, which is precisely the ENG-5297 zombie-pod bug). Naming
|
|
927
|
+
* the four safe categories also forces whoever adds the sixth to decide which
|
|
928
|
+
* side it belongs on.
|
|
929
|
+
*
|
|
930
|
+
* Residual risk, stated plainly — and this is the known hole in the gate, not a
|
|
931
|
+
* theoretical one. These categories are derived from npm/pnpm's error text,
|
|
932
|
+
* which carries no notion of install PHASE, so a later-phase failure can wear an
|
|
933
|
+
* early-phase label: `ERR_PNPM_FETCH_<5xx>` is pnpm's TARBALL fetch error yet
|
|
934
|
+
* maps to `registry_unreachable`, and `detectReasonFromText` matches
|
|
935
|
+
* ECONNRESET/ETIMEDOUT/ENOTFOUND anywhere in stderr. A CDN 502 or a mid-download
|
|
936
|
+
* reset therefore degrades even though extraction had begun. npm rolls the reify
|
|
937
|
+
* back, but rollback restores FILES only — it does not restart the esbuild
|
|
938
|
+
* service child process that died when its binary was replaced, which is the
|
|
939
|
+
* actual harm chain in ENG-5297. So this gate does not close that case.
|
|
940
|
+
*
|
|
941
|
+
* The trade is still taken knowingly: closing it properly means stamping the
|
|
942
|
+
* phase where it is actually known (a `treePossiblyMutated` flag set at the
|
|
943
|
+
* throw site in `upgradeCliWithPackageManager`) or probing esbuild/Vite health
|
|
944
|
+
* after an `UpgradeFailed`, both of which change the shared
|
|
945
|
+
* `DependencyUpgradeError` contract and belong in their own change. Meanwhile
|
|
946
|
+
* this gate is strictly better than the previous behavior, which degraded on
|
|
947
|
+
* every upgrade failure without exception, and exiting on these categories
|
|
948
|
+
* instead would amplify a registry outage into a fleet-wide restart.
|
|
949
|
+
*/
|
|
950
|
+
const RESOLUTION_PHASE_UPGRADE_CATEGORIES = new Set([
|
|
951
|
+
"not_in_registry",
|
|
952
|
+
"registry_auth_failed",
|
|
953
|
+
"registry_unreachable",
|
|
954
|
+
"tls_failed",
|
|
955
|
+
]);
|
|
956
|
+
/**
|
|
957
|
+
* True when a failed auto-upgrade could have mutated the live dependency tree,
|
|
958
|
+
* and therefore may have killed the running esbuild service out from under
|
|
959
|
+
* Vite. Unknown/unclassified categories deliberately answer `true`: ENG-5297
|
|
960
|
+
* arrived as `category: "unknown"`, and a category this function has never seen
|
|
961
|
+
* must fail closed (exit) rather than inherit the degrade path by default.
|
|
962
|
+
*
|
|
963
|
+
* This reduces to `category === "unknown"` against today's five-member union —
|
|
964
|
+
* see `RESOLUTION_PHASE_UPGRADE_CATEGORIES` for why the set is kept rather than
|
|
965
|
+
* collapsed to that comparison.
|
|
966
|
+
*
|
|
967
|
+
* The parameter is `string`, not `DependencyUpgradeError["category"]`, because
|
|
968
|
+
* that is the actual runtime contract: the payload is deserialized from the
|
|
969
|
+
* socket, so the five-member union is a promise about our own writers rather
|
|
970
|
+
* than a guarantee about the value — which is also why the set above is a
|
|
971
|
+
* `ReadonlySet<string>`. It is also what lets the fail-closed default be
|
|
972
|
+
* asserted for a category a FUTURE CLI adds; that case is not expressible
|
|
973
|
+
* through an `UpgradeFailed` marker without an `as` cast, and it is the one this
|
|
974
|
+
* gate most needs to keep.
|
|
975
|
+
*
|
|
976
|
+
* `"unknown"` is itself coarse; the two sub-cases sit on opposite sides of the
|
|
977
|
+
* question this function asks, and only one of them is a false positive:
|
|
978
|
+
*
|
|
979
|
+
* no install command `resolveCommand` returned nothing, so `child_process.exec`
|
|
980
|
+
* was never reached (automatic-upgrades.ts, the
|
|
981
|
+
* `if (!installCommand)` early throw). The tree is
|
|
982
|
+
* provably untouched and exiting is a false positive.
|
|
983
|
+
* version validation `getCurrentCliVersion()` returned nothing / mismatched.
|
|
984
|
+
* That check runs AFTER `await promise` resolves — i.e.
|
|
985
|
+
* after the global install completed — so the tree
|
|
986
|
+
* genuinely moved and exiting is correct.
|
|
987
|
+
*
|
|
988
|
+
* Only the first is a false positive, and it is not distinguishable from data
|
|
989
|
+
* already on `serverError`: both arrive as `category: "unknown"` with no
|
|
990
|
+
* `npmErrorCode`, and the only thing separating them is free-text English in
|
|
991
|
+
* `rawError`. Keying the exit decision on that string would be the same
|
|
992
|
+
* phase-blind text proxy this file already flags as the gate's weak point.
|
|
993
|
+
* Splitting them properly means a flag stamped at the throw site on
|
|
994
|
+
* `DependencyUpgradeError` — a change to a shared contract, deliberately not
|
|
995
|
+
* made here. Until then both land on exit, the direction this function is meant
|
|
996
|
+
* to err in.
|
|
997
|
+
*
|
|
998
|
+
* Note on cost if the false positive fires: SABS pods run `RestartPolicy: Never`,
|
|
999
|
+
* so the retry is a pod REPLACEMENT driven by the orchestrator, not an in-pod
|
|
1000
|
+
* crash loop. A deterministic, environment-derived cause would therefore
|
|
1001
|
+
* re-trigger in each replacement, and no per-process bound inside this file
|
|
1002
|
+
* could stop that — a replacement is a fresh process with a fresh counter. Any
|
|
1003
|
+
* real bound belongs in the orchestrator that decides to replace.
|
|
1004
|
+
*/
|
|
1005
|
+
export function upgradeCategoryCouldTaintTree(category) {
|
|
1006
|
+
return !RESOLUTION_PHASE_UPGRADE_CATEGORIES.has(category);
|
|
1007
|
+
}
|
|
698
1008
|
/** Decide how the startup catch handles an error: degrade (record, keep Vite up)
|
|
699
1009
|
* for an app-install failure (`InitialInstallFailed`) or a best-effort
|
|
700
1010
|
* CLI/library auto-upgrade failure (`UpgradeFailed`), or exit for anything else
|
|
701
1011
|
* (lock/sync/genuinely-unusable upgrade env). Pure + unit-tested. Does NOT call
|
|
702
1012
|
* process.exit. */
|
|
703
|
-
export function handleStartupError(error, status, logger) {
|
|
1013
|
+
export function handleStartupError(error, status, logger, options) {
|
|
704
1014
|
if (error instanceof InitialInstallFailed) {
|
|
705
1015
|
status.serverErrors.push(error.serverError);
|
|
706
1016
|
logger.error("[dev-server] initial dependency install failed; keeping dev server up", getErrorMeta(error));
|
|
@@ -712,17 +1022,56 @@ export function handleStartupError(error, status, logger) {
|
|
|
712
1022
|
return "degrade";
|
|
713
1023
|
}
|
|
714
1024
|
if (error instanceof UpgradeFailed) {
|
|
715
|
-
// The auto-upgrade is best-effort and the pod is still running the current
|
|
716
|
-
// version, so a failed upgrade degrades instead of crash-looping. Recording
|
|
717
|
-
// here also makes the failure observable: the process survives to the next
|
|
718
|
-
// metrics export tick, which the pre-exit `process.exit(1)` always lost.
|
|
719
1025
|
status.serverErrors.push(error.serverError);
|
|
720
|
-
|
|
1026
|
+
// The upgrade is an in-place `npm install` into the LIVE node_modules, and
|
|
1027
|
+
// its install manifest includes esbuild. A failure part-way through can
|
|
1028
|
+
// therefore have already killed the running esbuild service ("The service
|
|
1029
|
+
// was stopped"), which rejects vitePromise and leaves a dev server that can
|
|
1030
|
+
// never serve a request again. "Keeping the current version" is only true
|
|
1031
|
+
// when the tree survived, and we cannot cheaply prove that it did.
|
|
1032
|
+
//
|
|
1033
|
+
// So the decision turns on who owns the process AND on whether the failure
|
|
1034
|
+
// could have mutated the tree at all:
|
|
1035
|
+
// - disposable sandbox (SABS pod) + a failure that could have touched
|
|
1036
|
+
// `node_modules`: exit. The pod is cheap and SABS already reaps
|
|
1037
|
+
// terminal pods (they run RestartPolicy: Never), so exiting yields a
|
|
1038
|
+
// clean replacement. Degrading instead produced ENG-5297 — 85 minutes
|
|
1039
|
+
// of a Ready pod answering 500s.
|
|
1040
|
+
// - disposable sandbox + a resolution-phase failure: degrade. npm never
|
|
1041
|
+
// got as far as extraction, so the tree is intact and there is nothing
|
|
1042
|
+
// for a replacement pod to fix — see
|
|
1043
|
+
// `upgradeCategoryCouldTaintTree`, which also explains why exiting on
|
|
1044
|
+
// these would amplify a registry outage into a fleet-wide restart.
|
|
1045
|
+
// - anything not flagged as a CSB sandbox: degrade. Killing a dev server
|
|
1046
|
+
// the developer started themselves over a best-effort upgrade is worse
|
|
1047
|
+
// than running the older version. Note the carve-out is narrower than
|
|
1048
|
+
// "not in the cloud": the local `csb-mock-server` emulator also sets
|
|
1049
|
+
// `SUPERBLOCKS_IS_CSB=true`, so local code-mode dev servers exit here
|
|
1050
|
+
// too. That is intended (the emulator exists to behave like a pod), but
|
|
1051
|
+
// nothing in the emulator restarts the child — see
|
|
1052
|
+
// `isDisposableSandbox`.
|
|
1053
|
+
const outcome = options?.disposableSandbox &&
|
|
1054
|
+
upgradeCategoryCouldTaintTree(error.serverError.category)
|
|
1055
|
+
? "exit"
|
|
1056
|
+
: "degrade";
|
|
721
1057
|
devServerMetrics.recordUpgradeFailure({
|
|
722
1058
|
category: error.serverError.category,
|
|
723
1059
|
npmErrorCode: error.serverError.npmErrorCode,
|
|
724
1060
|
hasAnyRegistryConfigured: error.serverError.hasAnyRegistryConfigured,
|
|
1061
|
+
outcome,
|
|
725
1062
|
});
|
|
1063
|
+
if (outcome === "exit") {
|
|
1064
|
+
logger.error("[dev-server] dependency upgrade failed in a disposable sandbox; exiting so the sandbox is replaced (the live dependency tree may be tainted)", getErrorMeta(error));
|
|
1065
|
+
// No `devServerMetrics.flush()` here: `record()` and `flush()` gate on
|
|
1066
|
+
// the same `isTelemetryInitialized()` check and nothing async runs
|
|
1067
|
+
// between them, so if the sample above was buffered, flush would bail on
|
|
1068
|
+
// the identical condition — and if it was not buffered, there is nothing
|
|
1069
|
+
// to drain. The caller's `flushTelemetryBeforeExit` (which awaits
|
|
1070
|
+
// `shutdownTelemetry()`) is what actually forces the export before
|
|
1071
|
+
// `process.exit(1)`.
|
|
1072
|
+
return "exit";
|
|
1073
|
+
}
|
|
1074
|
+
logger.error("[dev-server] dependency upgrade failed; keeping the current version", getErrorMeta(error));
|
|
726
1075
|
return "degrade";
|
|
727
1076
|
}
|
|
728
1077
|
return "exit";
|
|
@@ -880,6 +1229,7 @@ export async function dev(options) {
|
|
|
880
1229
|
superblocksBaseUrl: tokenConfig.superblocksBaseUrl,
|
|
881
1230
|
rpcClient,
|
|
882
1231
|
logger,
|
|
1232
|
+
removeManualCommitsEnabled: featureFlags.removeManualCommitsEnabled(),
|
|
883
1233
|
});
|
|
884
1234
|
logger.info("Checking if local files are synced with the server");
|
|
885
1235
|
try {
|
|
@@ -1410,20 +1760,18 @@ export async function dev(options) {
|
|
|
1410
1760
|
// which is why that runner reports zero samples in every env
|
|
1411
1761
|
// despite real upgrades emitting it (spans survive because they
|
|
1412
1762
|
// flush on the faster batch processor; the metric does not).
|
|
1413
|
-
//
|
|
1414
|
-
//
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1763
|
+
// The timeout-raced flush is shared with the fatal-exit path
|
|
1764
|
+
// (`flushTelemetryWithTimeout`) — this site used to carry its own
|
|
1765
|
+
// copy of the race, and that copy leaked its timer.
|
|
1766
|
+
// `flushTelemetryWithTimeout` resolves `true` only when a flush
|
|
1767
|
+
// actually ran — `false` on internal shutdown error, when
|
|
1768
|
+
// telemetry was disabled, or when the timeout won — so the log
|
|
1769
|
+
// below reflects whether samples were really flushed, not merely
|
|
1770
|
+
// that the race settled. It rejects only if shutdownTelemetry()
|
|
1771
|
+
// breaks its never-reject contract, so the catch stays defensive.
|
|
1419
1772
|
let telemetryFlushed = false;
|
|
1420
1773
|
try {
|
|
1421
|
-
await
|
|
1422
|
-
shutdownTelemetry().then((flushed) => {
|
|
1423
|
-
telemetryFlushed = flushed;
|
|
1424
|
-
}),
|
|
1425
|
-
new Promise((resolve) => setTimeout(resolve, TELEMETRY_FLUSH_TIMEOUT_MS)),
|
|
1426
|
-
]);
|
|
1774
|
+
telemetryFlushed = await flushTelemetryWithTimeout();
|
|
1427
1775
|
}
|
|
1428
1776
|
catch (e) {
|
|
1429
1777
|
logger.error("Error flushing telemetry before CLI restart", getErrorMeta(e));
|
|
@@ -1441,18 +1789,54 @@ export async function dev(options) {
|
|
|
1441
1789
|
});
|
|
1442
1790
|
}
|
|
1443
1791
|
catch (error) {
|
|
1444
|
-
if (handleStartupError(error, devServerStatus, logger
|
|
1792
|
+
if (handleStartupError(error, devServerStatus, logger, {
|
|
1793
|
+
disposableSandbox: isDisposableSandbox(),
|
|
1794
|
+
}) === "degrade") {
|
|
1445
1795
|
// app-install failure: do NOT exit — fall through to Vite startup below.
|
|
1446
1796
|
// upload + CLI-restart were already skipped (the rejecting join threw first).
|
|
1447
1797
|
}
|
|
1448
1798
|
else {
|
|
1449
|
-
logger.error(
|
|
1799
|
+
logger.error(
|
|
1800
|
+
// Names the stage, because `buildFatalExitLog` below carries only
|
|
1801
|
+
// the generic `handler=startupFailure`. Two stages reach here now:
|
|
1802
|
+
// sync/lock/setup, and (since ENG-5297) a dependency upgrade that
|
|
1803
|
+
// failed in a CSB sandbox and may have tainted the live tree.
|
|
1804
|
+
"[dev-server] Startup failed during sync/lock/setup, or a dependency upgrade failed in a CSB sandbox (exiting with code 1)", getErrorMeta(error));
|
|
1805
|
+
// Emitted BEFORE any teardown, and as a synchronous log line rather
|
|
1806
|
+
// than a counter, per the convention documented in
|
|
1807
|
+
// dev-utils/fatal-exit.mts: at exit time stdout is the only sink we
|
|
1808
|
+
// can rely on, so the reason has to land even if the flush below
|
|
1809
|
+
// never completes. The `outcome="exit"` metric is a complement to
|
|
1810
|
+
// this line, not a substitute for it.
|
|
1811
|
+
logger.error(buildFatalExitLog({
|
|
1812
|
+
handler: "startupFailure",
|
|
1813
|
+
exitCode: 1,
|
|
1814
|
+
isWarm: parseIsWarm(process.env.SUPERBLOCKS_WARM_STANDBY),
|
|
1815
|
+
}));
|
|
1450
1816
|
try {
|
|
1451
|
-
|
|
1452
|
-
|
|
1817
|
+
// `shutdown()`, NOT `shutdownAndExit()`. The latter ends in an
|
|
1818
|
+
// unconditional `finally { process.exit(1) }`; `process.exit` is
|
|
1819
|
+
// synchronous, so every statement below it would be dead code
|
|
1820
|
+
// whenever `lockService` is set — which is the normal state in a
|
|
1821
|
+
// SABS pod, exactly the population this exit path targets. The
|
|
1822
|
+
// telemetry flush was silently never running. `shutdown()` does
|
|
1823
|
+
// the same teardown without owning the exit, matching how
|
|
1824
|
+
// `runGracefulShutdown` in dev-server.mts drives it; the
|
|
1825
|
+
// `finally` below performs the exit instead.
|
|
1826
|
+
//
|
|
1827
|
+
// Ordering, per-step error handling, and per-step timeouts all
|
|
1828
|
+
// live in `runFatalExitShutdown` so the two fatal paths cannot
|
|
1829
|
+
// drift apart.
|
|
1830
|
+
await runFatalExitShutdown({
|
|
1831
|
+
logger,
|
|
1832
|
+
removeIntegrationCache: () => aiService?.removeIntegrationCache(),
|
|
1833
|
+
shutdownLockService: () => lockService?.shutdown({ serverInitiated: false }),
|
|
1834
|
+
});
|
|
1453
1835
|
}
|
|
1454
1836
|
finally {
|
|
1455
|
-
//
|
|
1837
|
+
// The only exit on this path now that the lock service no longer
|
|
1838
|
+
// owns one. `runFatalExitShutdown` never throws and bounds every
|
|
1839
|
+
// step, so this runs regardless of what teardown did.
|
|
1456
1840
|
process.exit(1);
|
|
1457
1841
|
}
|
|
1458
1842
|
}
|
|
@@ -1485,15 +1869,32 @@ export async function dev(options) {
|
|
|
1485
1869
|
await joinUpgradeThenInstall("before Vite startup");
|
|
1486
1870
|
}
|
|
1487
1871
|
catch (error) {
|
|
1488
|
-
if (handleStartupError(error, devServerStatus, logger
|
|
1872
|
+
if (handleStartupError(error, devServerStatus, logger, {
|
|
1873
|
+
disposableSandbox: isDisposableSandbox(),
|
|
1874
|
+
}) === "exit") {
|
|
1489
1875
|
logger.error("[dev-server] Startup failed during pre-Vite install join (exiting with code 1)", getErrorMeta(error));
|
|
1876
|
+
// Same convention as the sync/lock catch above: a synchronous
|
|
1877
|
+
// `event=dev_server_fatal_exit` line, written before teardown, is the
|
|
1878
|
+
// thing oncall queries for this exit class.
|
|
1879
|
+
logger.error(buildFatalExitLog({
|
|
1880
|
+
handler: "startupFailure",
|
|
1881
|
+
exitCode: 1,
|
|
1882
|
+
isWarm: parseIsWarm(process.env.SUPERBLOCKS_WARM_STANDBY),
|
|
1883
|
+
}));
|
|
1490
1884
|
try {
|
|
1491
|
-
|
|
1492
|
-
|
|
1885
|
+
// `shutdown()`, NOT `shutdownAndExit()` — same reason as the
|
|
1886
|
+
// sync/lock catch above: `shutdownAndExit()` exits the process
|
|
1887
|
+
// synchronously in its own `finally`, which made everything
|
|
1888
|
+
// sequenced after it unreachable.
|
|
1889
|
+
await runFatalExitShutdown({
|
|
1890
|
+
logger,
|
|
1891
|
+
removeIntegrationCache: () => aiService?.removeIntegrationCache(),
|
|
1892
|
+
shutdownLockService: () => lockService?.shutdown({ serverInitiated: false }),
|
|
1893
|
+
});
|
|
1493
1894
|
}
|
|
1494
1895
|
finally {
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1896
|
+
// The only exit on this path; here so a thrown shutdown path can't
|
|
1897
|
+
// leave the process hanging on a stuck handle.
|
|
1497
1898
|
process.exit(1);
|
|
1498
1899
|
}
|
|
1499
1900
|
}
|