@limrun/api 0.46.8 → 0.46.9

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.
@@ -7,6 +7,34 @@
7
7
 
8
8
  import { createEventSource, type EventSourceClient, type EventSourceMessage } from 'eventsource-client';
9
9
  import { nodeProxyTransport } from './internal/proxy-transport';
10
+ import { sseFetch } from './internal/sse-fetch';
11
+ import type { Fetch } from './internal/builtin-types';
12
+
13
+ /**
14
+ * Give-up policy for the exec event stream. The EventSource reconnects
15
+ * forever on its own, so a dead exec (deleted instance, expired record)
16
+ * would otherwise retry until the one-hour completion timeout. Proof of
17
+ * life resets the clock: a delivered event, or a clean connection that
18
+ * stays open healthyConnectionMs (long compiles stream nothing for
19
+ * minutes while intermediaries kill idle connections). The window is
20
+ * wide enough to ride out real transient outages (an ingress redeploy, a
21
+ * VPN reconnect); tightening it trades outage tolerance for faster
22
+ * dead-exec detection. It is measured in time, not attempts: the server
23
+ * steers the retry delay through SSE `retry:` frames, so an attempt
24
+ * count would put the give-up horizon under remote control.
25
+ *
26
+ * @internal Mutable for tests only.
27
+ */
28
+ export const sseStreamPolicy = {
29
+ healthyConnectionMs: 15_000,
30
+ giveUpAfterMs: 300_000,
31
+ };
32
+
33
+ /** The event stream kept failing past the give-up window; remote state unknown. */
34
+ export class ExecStreamLostError extends Error {}
35
+
36
+ /** The server closed the event stream for good (HTTP 204). */
37
+ export class ExecStreamClosedError extends Error {}
10
38
 
11
39
  // =============================================================================
12
40
  // Types
@@ -211,6 +239,14 @@ export type ExecResult = {
211
239
  * running and may yet succeed.
212
240
  */
213
241
  timedOut?: boolean;
242
+ /**
243
+ * Why the client gave up, present exactly when timedOut is true.
244
+ * 'timeout' is the completion budget expiring on a live stream;
245
+ * 'stream-lost' means the event stream kept failing (the execution may
246
+ * no longer exist); 'stream-closed' means the server ended the stream
247
+ * for good.
248
+ */
249
+ incomplete?: { reason: 'timeout' | 'stream-lost' | 'stream-closed'; message: string };
214
250
  };
215
251
 
216
252
  export type AppStoreEvent = {
@@ -327,6 +363,10 @@ export class ExecChildProcess implements PromiseLike<ExecResult> {
327
363
  private abortController = new AbortController();
328
364
  private sseConnection: EventSourceClient | null = null;
329
365
  private killed = false;
366
+ // Start of the stream's current unbroken failure streak, 0 while
367
+ // healthy. run() reads it to classify a completion timeout that
368
+ // expired while the stream was already dead.
369
+ private streamDeadSince = 0;
330
370
  private detached = false;
331
371
  private appStoreEvent: AppStoreEvent | null = null;
332
372
  private playstoreEvent: PlaystoreEvent | null = null;
@@ -501,22 +541,41 @@ export class ExecChildProcess implements PromiseLike<ExecResult> {
501
541
  }
502
542
  let exitCode: number;
503
543
  let timedOut = false;
544
+ let incomplete: ExecResult['incomplete'];
504
545
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
505
546
  try {
506
547
  exitCode = await Promise.race([
507
548
  this.connectSSE(eventsUrl),
508
549
  new Promise<never>((_, reject) => {
509
- timeoutId = setTimeout(() => reject(new Error('SSE timeout')), timeoutMs);
550
+ timeoutId = setTimeout(() => reject(new Error('SSE completion timeout')), timeoutMs);
510
551
  }),
511
552
  ]);
512
- } catch {
553
+ } catch (err) {
513
554
  if (this.killed) {
514
555
  log('debug', 'Execution killed');
515
556
  exitCode = -1;
516
557
  } else {
517
- // The client stopped waiting; the remote build may still be running.
518
- // The fabricated exit code must not read as a build failure.
519
- log('warn', 'SSE completion timeout');
558
+ // The client stopped waiting; the fabricated exit code must not
559
+ // read as a build failure. The structured reason lets callers give
560
+ // the right advice: a dead stream means the execution may be gone,
561
+ // while a genuine timeout means it may still be running. A budget
562
+ // that expires mid-failure-streak (short run timeouts undercut the
563
+ // stream's own give-up window) counts as a stream problem too.
564
+ let message = err instanceof Error ? err.message : String(err);
565
+ const streakMs = this.streamDeadSince > 0 ? Date.now() - this.streamDeadSince : 0;
566
+ let reason: NonNullable<ExecResult['incomplete']>['reason'] =
567
+ err instanceof ExecStreamClosedError ? 'stream-closed'
568
+ : err instanceof ExecStreamLostError ? 'stream-lost'
569
+ : 'timeout';
570
+ if (reason === 'timeout' && streakMs > 0) {
571
+ reason = 'stream-lost';
572
+ message =
573
+ `the completion budget expired while the event stream had already been failing ` +
574
+ `for ${Math.round(streakMs / 1000)}s; the execution may no longer exist ` +
575
+ `(instance deleted or record expired)`;
576
+ }
577
+ incomplete = { reason, message };
578
+ log('warn', message);
520
579
  exitCode = 1;
521
580
  timedOut = true;
522
581
  }
@@ -552,6 +611,7 @@ export class ExecChildProcess implements PromiseLike<ExecResult> {
552
611
  ...(this.appStoreEvent ? { appstore: this.appStoreEvent } : {}),
553
612
  ...(this.playstoreEvent ? { playstore: this.playstoreEvent } : {}),
554
613
  ...(timedOut ? { timedOut } : {}),
614
+ ...(incomplete ? { incomplete } : {}),
555
615
  };
556
616
 
557
617
  this.log('debug', `Execution finished: ${result.status} (exit ${result.exitCode})`);
@@ -561,75 +621,174 @@ export class ExecChildProcess implements PromiseLike<ExecResult> {
561
621
  /**
562
622
  * Opens an SSE connection and routes streamed events to the exposed command/stdout/stderr streams.
563
623
  * Resolves with the exit code when an 'exitCode' event arrives.
564
- * Rejects when the abort signal fires (kill or cleanup).
624
+ * Rejects when the abort signal fires (kill or cleanup), when the server
625
+ * closes the stream for good, or when the give-up clock runs out.
565
626
  */
566
627
  private connectSSE(eventsUrl: string): Promise<number> {
567
628
  return new Promise<number>((resolve, reject) => {
568
- if (this.abortController.signal.aborted) {
629
+ const signal = this.abortController.signal;
630
+ if (signal.aborted) {
569
631
  reject(new Error('killed'));
570
632
  return;
571
633
  }
572
634
 
573
- try {
574
- const eventSource = createEventSource({
575
- url: eventsUrl,
576
- fetch: nodeProxyTransport.fetch,
577
- headers: { Authorization: `Bearer ${this.options.token}` },
578
- onMessage: (message: EventSourceMessage) => {
579
- const data = typeof message.data === 'string' ? message.data : String(message.data ?? '');
580
- const eventType = message.event;
581
- if (eventType === 'command') {
582
- this.command.emit('data', data);
583
- } else if (eventType === 'stdout') {
584
- this.stdout.emit('data', data);
585
- } else if (eventType === 'stderr') {
586
- this.stderr.emit('data', data);
587
- } else if (eventType === 'testflight') {
588
- try {
589
- this.appStoreEvent = JSON.parse(data) as AppStoreEvent;
590
- } catch {
591
- // The wire event itself proves the server ran the App Store upload,
592
- // so never let a payload glitch look like a missing feature.
593
- this.appStoreEvent = { state: 'unknown' };
594
- this.log('warn', `SSE testflight event has invalid data: ${data}`);
595
- }
596
- } else if (eventType === 'playstore') {
597
- try {
598
- this.playstoreEvent = JSON.parse(data) as PlaystoreEvent;
599
- } catch {
600
- // Same contract as the App Store upload event: its presence proves the server
601
- // ran the Play Store step, so a payload glitch must not
602
- // read as a missing feature.
603
- this.playstoreEvent = { state: 'unknown' };
604
- this.log('warn', `SSE playstore event has invalid data: ${data}`);
605
- }
606
- } else if (eventType === 'exitCode') {
607
- const exitCode = parseInt(data, 10);
608
- if (Number.isNaN(exitCode)) {
609
- this.log('warn', `SSE exitCode event has invalid data: ${data}`);
610
- return;
611
- }
612
- this.log('debug', `Execution completed via SSE: exitCode=${exitCode}`);
613
- resolve(exitCode);
635
+ // Settle-once pair. Both paths close the source: the EventSource
636
+ // reconnects on its own otherwise, even after the promise is done.
637
+ let settled = false;
638
+ let connectedAt = 0;
639
+ let lastCycleAt = 0;
640
+ let lastCycleMono = 0;
641
+ let proofOfLifeThisCycle = false;
642
+ let cleanResponseThisCycle = false;
643
+ let lastStreamError: Error | undefined;
644
+ const cleanup = () => {
645
+ eventSource.close();
646
+ signal.removeEventListener('abort', onAbort);
647
+ };
648
+ const succeed = (exitCode: number) => {
649
+ if (settled) return;
650
+ settled = true;
651
+ cleanup();
652
+ resolve(exitCode);
653
+ };
654
+ const fail = (error: Error) => {
655
+ if (settled) return;
656
+ settled = true;
657
+ cleanup();
658
+ reject(error);
659
+ };
660
+ const onAbort = () => fail(new Error('killed'));
661
+
662
+ // Stream health is judged at the fetch surface, where the response
663
+ // status is visible; the library fires onConnect for error responses
664
+ // too, so an error page held open must not read as a healthy
665
+ // connection. An HTTP 204 makes the EventSource close for good
666
+ // without reporting anything, so it must fail here. A rejected fetch
667
+ // never reaches onDisconnect (the hazard sseFetch exists for):
668
+ // capture it for the give-up message and let the clock decide, so
669
+ // one transient refusal does not kill a live build.
670
+ const fetchWithStreamPolicy: Fetch = async (input, init) => {
671
+ const response = await nodeProxyTransport.fetch(input, init);
672
+ if (response.status === 204) {
673
+ fail(new ExecStreamClosedError(`event stream to ${eventsUrl} was closed by the server (HTTP 204)`));
674
+ } else if (!response.ok) {
675
+ lastStreamError = new Error(`server answered HTTP ${response.status}`);
676
+ }
677
+ cleanResponseThisCycle = response.ok;
678
+ return response;
679
+ };
680
+
681
+ const eventSource = createEventSource({
682
+ url: eventsUrl,
683
+ fetch: sseFetch(fetchWithStreamPolicy, (err) => {
684
+ lastStreamError = err instanceof Error ? err : new Error(String(err));
685
+ }),
686
+ headers: { Authorization: `Bearer ${this.options.token}` },
687
+ onConnect: () => {
688
+ connectedAt = Date.now();
689
+ },
690
+ // Comments count as proof of life, so a server-side keepalive
691
+ // works without a client change (onMessage never sees them).
692
+ onComment: () => {
693
+ proofOfLifeThisCycle = true;
694
+ },
695
+ // Fires once per broken cycle, before the retry timer is armed, on
696
+ // both failure paths (request rejected, stream ended). This is
697
+ // where the give-up clock runs.
698
+ onScheduleReconnect: () => {
699
+ if (settled || this.killed) {
700
+ return;
701
+ }
702
+ const now = Date.now();
703
+ const mono = performance.now();
704
+ const livedMs = connectedAt > 0 ? now - connectedAt : 0;
705
+ connectedAt = 0;
706
+ // Date.now() is wall clock: a laptop waking from sleep (or a
707
+ // clock step) would arrive with the whole window already
708
+ // "elapsed" and fail on its first attempt. Sleep is the wall
709
+ // clock advancing while the monotonic clock stands still; a
710
+ // large drift between the two restarts the streak. Long server
711
+ // retry delays and hanging connects advance both clocks equally
712
+ // and keep counting.
713
+ if (this.streamDeadSince > 0 && lastCycleAt > 0) {
714
+ const wallGapMs = now - lastCycleAt;
715
+ const monoGapMs = mono - lastCycleMono;
716
+ if (wallGapMs - monoGapMs > 30_000) {
717
+ this.streamDeadSince = now;
614
718
  }
615
- },
616
- onDisconnect: () => {
617
- if (!this.killed) {
618
- this.log('warn', 'SSE disconnected');
719
+ }
720
+ lastCycleAt = now;
721
+ lastCycleMono = mono;
722
+ const healthy =
723
+ proofOfLifeThisCycle ||
724
+ (cleanResponseThisCycle && livedMs >= sseStreamPolicy.healthyConnectionMs);
725
+ proofOfLifeThisCycle = false;
726
+ cleanResponseThisCycle = false;
727
+ if (healthy) {
728
+ this.streamDeadSince = 0;
729
+ lastStreamError = undefined;
730
+ return;
731
+ }
732
+ if (this.streamDeadSince === 0) {
733
+ this.streamDeadSince = now;
734
+ this.log('warn', 'SSE disconnected; reconnecting');
735
+ return;
736
+ }
737
+ if (now - this.streamDeadSince >= sseStreamPolicy.giveUpAfterMs) {
738
+ const seconds = Math.round((now - this.streamDeadSince) / 1000);
739
+ const cause = lastStreamError ? `; last error: ${lastStreamError.message}` : '';
740
+ fail(
741
+ new ExecStreamLostError(
742
+ `event stream to ${eventsUrl} kept failing for ${seconds}s without delivering events${cause}; ` +
743
+ 'the execution may no longer exist (instance deleted or record expired)',
744
+ ),
745
+ );
746
+ }
747
+ },
748
+ onMessage: (message: EventSourceMessage) => {
749
+ this.streamDeadSince = 0;
750
+ proofOfLifeThisCycle = true;
751
+ lastStreamError = undefined;
752
+ const data = typeof message.data === 'string' ? message.data : String(message.data ?? '');
753
+ const eventType = message.event;
754
+ if (eventType === 'command') {
755
+ this.command.emit('data', data);
756
+ } else if (eventType === 'stdout') {
757
+ this.stdout.emit('data', data);
758
+ } else if (eventType === 'stderr') {
759
+ this.stderr.emit('data', data);
760
+ } else if (eventType === 'testflight') {
761
+ try {
762
+ this.appStoreEvent = JSON.parse(data) as AppStoreEvent;
763
+ } catch {
764
+ // The wire event itself proves the server ran the App Store upload,
765
+ // so never let a payload glitch look like a missing feature.
766
+ this.appStoreEvent = { state: 'unknown' };
767
+ this.log('warn', `SSE testflight event has invalid data: ${data}`);
619
768
  }
620
- },
621
- });
622
- this.sseConnection = eventSource;
623
-
624
- this.abortController.signal.addEventListener('abort', () => reject(new Error('killed')), {
625
- once: true,
626
- });
627
- } catch (err) {
628
- if (!this.killed) {
629
- this.log('warn', `SSE setup failed: ${err}`);
630
- }
631
- reject(err);
632
- }
769
+ } else if (eventType === 'playstore') {
770
+ try {
771
+ this.playstoreEvent = JSON.parse(data) as PlaystoreEvent;
772
+ } catch {
773
+ // Same contract as the App Store upload event: its presence proves the server
774
+ // ran the Play Store step, so a payload glitch must not
775
+ // read as a missing feature.
776
+ this.playstoreEvent = { state: 'unknown' };
777
+ this.log('warn', `SSE playstore event has invalid data: ${data}`);
778
+ }
779
+ } else if (eventType === 'exitCode') {
780
+ const exitCode = parseInt(data, 10);
781
+ if (Number.isNaN(exitCode)) {
782
+ this.log('warn', `SSE exitCode event has invalid data: ${data}`);
783
+ return;
784
+ }
785
+ this.log('debug', `Execution completed via SSE: exitCode=${exitCode}`);
786
+ succeed(exitCode);
787
+ }
788
+ },
789
+ });
790
+ this.sseConnection = eventSource;
791
+ signal.addEventListener('abort', onAbort, { once: true });
633
792
  });
634
793
  }
635
794
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.46.8'; // x-release-please-version
1
+ export const VERSION = '0.46.9'; // x-release-please-version
package/version.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.46.8";
1
+ export declare const VERSION = "0.46.9";
2
2
  //# sourceMappingURL=version.d.mts.map
package/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.46.8";
1
+ export declare const VERSION = "0.46.9";
2
2
  //# sourceMappingURL=version.d.ts.map
package/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
- exports.VERSION = '0.46.8'; // x-release-please-version
4
+ exports.VERSION = '0.46.9'; // x-release-please-version
5
5
  //# sourceMappingURL=version.js.map
package/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.46.8'; // x-release-please-version
1
+ export const VERSION = '0.46.9'; // x-release-please-version
2
2
  //# sourceMappingURL=version.mjs.map