instar 1.3.387 → 1.3.389
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/dist/feedback-factory/cutoverReadiness.d.ts +23 -0
- package/dist/feedback-factory/cutoverReadiness.d.ts.map +1 -1
- package/dist/feedback-factory/cutoverReadiness.js +32 -2
- package/dist/feedback-factory/cutoverReadiness.js.map +1 -1
- package/dist/server/PeerStreamProxy.d.ts +104 -0
- package/dist/server/PeerStreamProxy.d.ts.map +1 -0
- package/dist/server/PeerStreamProxy.js +270 -0
- package/dist/server/PeerStreamProxy.js.map +1 -0
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/1.3.388.md +23 -0
- package/upgrades/1.3.389.md +45 -0
- package/upgrades/side-effects/cutover-parity-lock-maxhold.md +95 -0
- package/upgrades/side-effects/dashboard-stream-phase1-proxy.md +61 -0
|
@@ -115,11 +115,25 @@ export interface CutoverReadinessDeps {
|
|
|
115
115
|
runImportDryRun?: (() => Promise<ImportRunResult>) | null;
|
|
116
116
|
/** Freshness bound for the parity window (default 6h). */
|
|
117
117
|
maxPassStalenessMs?: number;
|
|
118
|
+
/**
|
|
119
|
+
* Wall-clock max-hold for a single live fetch (parity pass OR import dry-run),
|
|
120
|
+
* enforced at the single-flight-lock boundary (default 12m). This is a
|
|
121
|
+
* defense-in-depth backstop ON TOP OF whatever internal timeout the injected
|
|
122
|
+
* fetch carries: if that fetch ever fails to settle (e.g. an AbortSignal that
|
|
123
|
+
* doesn't fire on a stalled socket, or a compare step with no timeout of its
|
|
124
|
+
* own), the lock would otherwise be held forever and every subsequent pass +
|
|
125
|
+
* dry-run is refused with "already in flight" — exactly the ~85-minute stuck
|
|
126
|
+
* lock observed in #948. The max-hold guarantees the lock releases within a
|
|
127
|
+
* bounded time so the next sweep can retry; it sits comfortably above
|
|
128
|
+
* HttpParitySource's 600s total budget so it only fires when that inner budget
|
|
129
|
+
* has genuinely failed, never on a legitimately-slow-but-working pass. */
|
|
130
|
+
maxLiveFetchMs?: number;
|
|
118
131
|
now?: () => number;
|
|
119
132
|
}
|
|
120
133
|
export declare class CutoverReadiness {
|
|
121
134
|
private readonly d;
|
|
122
135
|
private readonly maxStaleMs;
|
|
136
|
+
private readonly maxLiveFetchMs;
|
|
123
137
|
/**
|
|
124
138
|
* Single-flight guard over the LIVE SOURCE FETCH — shared by the parity pass
|
|
125
139
|
* and the import dry-run because both page the full live source. Without it,
|
|
@@ -133,6 +147,15 @@ export declare class CutoverReadiness {
|
|
|
133
147
|
private liveFetchInFlight;
|
|
134
148
|
constructor(deps: CutoverReadinessDeps);
|
|
135
149
|
private nowMs;
|
|
150
|
+
/**
|
|
151
|
+
* Race an injected live-fetch promise against a wall-clock max-hold so the
|
|
152
|
+
* single-flight lock can NEVER be held indefinitely by a fetch that fails to
|
|
153
|
+
* settle (#948). On timeout this rejects (the caller's `finally` then releases
|
|
154
|
+
* the lock and the next sweep retries); the orphaned work is abandoned — a
|
|
155
|
+
* re-attempt is cheap and idempotent, an unreleasable lock is not. Uses real
|
|
156
|
+
* wall-clock time (NOT `nowMs()`), since the bug is a promise that never
|
|
157
|
+
* settles in real time regardless of an injected clock. */
|
|
158
|
+
private withMaxHold;
|
|
136
159
|
/** Persist an import IntegrityReport (server-side import tooling only). */
|
|
137
160
|
recordIntegrityReport(report: IntegrityReport, generatedAt?: string): void;
|
|
138
161
|
integrityStatus(): IntegrityStatus;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cutoverReadiness.d.ts","sourceRoot":"","sources":["../../src/feedback-factory/cutoverReadiness.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"cutoverReadiness.d.ts","sourceRoot":"","sources":["../../src/feedback-factory/cutoverReadiness.ts"],"names":[],"mappings":"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"}
|
|
@@ -24,9 +24,13 @@
|
|
|
24
24
|
import fs from 'node:fs';
|
|
25
25
|
import path from 'node:path';
|
|
26
26
|
const DEFAULT_MAX_PASS_STALENESS_MS = 6 * 60 * 60 * 1000;
|
|
27
|
+
/** 12m — above HttpParitySource's 600s total budget + a working pass's compare,
|
|
28
|
+
* so the lock-boundary max-hold is a true backstop, not a false-abort risk. */
|
|
29
|
+
const DEFAULT_MAX_LIVE_FETCH_MS = 12 * 60 * 1000;
|
|
27
30
|
export class CutoverReadiness {
|
|
28
31
|
d;
|
|
29
32
|
maxStaleMs;
|
|
33
|
+
maxLiveFetchMs;
|
|
30
34
|
/**
|
|
31
35
|
* Single-flight guard over the LIVE SOURCE FETCH — shared by the parity pass
|
|
32
36
|
* and the import dry-run because both page the full live source. Without it,
|
|
@@ -46,10 +50,36 @@ export class CutoverReadiness {
|
|
|
46
50
|
}
|
|
47
51
|
this.d = deps;
|
|
48
52
|
this.maxStaleMs = deps.maxPassStalenessMs ?? DEFAULT_MAX_PASS_STALENESS_MS;
|
|
53
|
+
this.maxLiveFetchMs = deps.maxLiveFetchMs ?? DEFAULT_MAX_LIVE_FETCH_MS;
|
|
49
54
|
}
|
|
50
55
|
nowMs() {
|
|
51
56
|
return this.d.now ? this.d.now() : Date.now();
|
|
52
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Race an injected live-fetch promise against a wall-clock max-hold so the
|
|
60
|
+
* single-flight lock can NEVER be held indefinitely by a fetch that fails to
|
|
61
|
+
* settle (#948). On timeout this rejects (the caller's `finally` then releases
|
|
62
|
+
* the lock and the next sweep retries); the orphaned work is abandoned — a
|
|
63
|
+
* re-attempt is cheap and idempotent, an unreleasable lock is not. Uses real
|
|
64
|
+
* wall-clock time (NOT `nowMs()`), since the bug is a promise that never
|
|
65
|
+
* settles in real time regardless of an injected clock. */
|
|
66
|
+
async withMaxHold(work, op) {
|
|
67
|
+
let timer;
|
|
68
|
+
const guard = new Promise((_resolve, reject) => {
|
|
69
|
+
timer = setTimeout(() => {
|
|
70
|
+
reject(new Error(`live ${op} fetch exceeded the max-hold budget (${this.maxLiveFetchMs}ms) — releasing the single-flight lock so a later sweep can retry; the underlying fetch did not settle within budget (see #948)`));
|
|
71
|
+
}, this.maxLiveFetchMs);
|
|
72
|
+
if (typeof timer.unref === 'function')
|
|
73
|
+
timer.unref();
|
|
74
|
+
});
|
|
75
|
+
try {
|
|
76
|
+
return await Promise.race([work, guard]);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (timer)
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
53
83
|
/** Persist an import IntegrityReport (server-side import tooling only). */
|
|
54
84
|
recordIntegrityReport(report, generatedAt) {
|
|
55
85
|
const envelope = {
|
|
@@ -109,7 +139,7 @@ export class CutoverReadiness {
|
|
|
109
139
|
this.liveFetchInFlight = { op: 'parity-pass', startedAt: new Date(this.nowMs()).toISOString() };
|
|
110
140
|
let result;
|
|
111
141
|
try {
|
|
112
|
-
result = await this.d.runParityCheck();
|
|
142
|
+
result = await this.withMaxHold(this.d.runParityCheck(), 'parity-pass');
|
|
113
143
|
}
|
|
114
144
|
catch (err) {
|
|
115
145
|
return { ok: false, reason: `parity check failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
@@ -142,7 +172,7 @@ export class CutoverReadiness {
|
|
|
142
172
|
this.liveFetchInFlight = { op: 'import-dry-run', startedAt: new Date(this.nowMs()).toISOString() };
|
|
143
173
|
let result;
|
|
144
174
|
try {
|
|
145
|
-
result = await this.d.runImportDryRun();
|
|
175
|
+
result = await this.withMaxHold(this.d.runImportDryRun(), 'import-dry-run');
|
|
146
176
|
}
|
|
147
177
|
catch (err) {
|
|
148
178
|
return { ok: false, reason: `import dry-run failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cutoverReadiness.js","sourceRoot":"","sources":["../../src/feedback-factory/cutoverReadiness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"cutoverReadiness.js","sourceRoot":"","sources":["../../src/feedback-factory/cutoverReadiness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAoG7B,MAAM,6BAA6B,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AACzD;gFACgF;AAChF,MAAM,yBAAyB,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAajD,MAAM,OAAO,gBAAgB;IACV,CAAC,CAAuB;IACxB,UAAU,CAAS;IACnB,cAAc,CAAS;IACxC;;;;;;;;;OASG;IACK,iBAAiB,GAAuE,IAAI,CAAC;IAErG,YAAY,IAA0B;QACpC,IAAI,IAAI,CAAC,sBAAsB,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,sBAAsB,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,mBAAmB,CAAC,EAAE,CAAC;YACxH,sEAAsE;YACtE,6EAA6E;YAC7E,MAAM,IAAI,KAAK,CAAC,6GAA6G,CAAC,CAAC;QACjI,CAAC;QACD,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC;QACd,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,kBAAkB,IAAI,6BAA6B,CAAC;QAC3E,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,cAAc,IAAI,yBAAyB,CAAC;IACzE,CAAC;IAEO,KAAK;QACX,OAAO,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;IAChD,CAAC;IAED;;;;;;;+DAO2D;IACnD,KAAK,CAAC,WAAW,CAAI,IAAgB,EAAE,EAAU;QACvD,IAAI,KAAgD,CAAC;QACrD,MAAM,KAAK,GAAG,IAAI,OAAO,CAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE;YACpD,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBACtB,MAAM,CAAC,IAAI,KAAK,CACd,QAAQ,EAAE,wCAAwC,IAAI,CAAC,cAAc,iIAAiI,CACvM,CAAC,CAAC;YACL,CAAC,EAAE,IAAI,CAAC,cAAc,CAAC,CAAC;YACxB,IAAI,OAAO,KAAK,CAAC,KAAK,KAAK,UAAU;gBAAE,KAAK,CAAC,KAAK,EAAE,CAAC;QACvD,CAAC,CAAC,CAAC;QACH,IAAI,CAAC;YACH,OAAO,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC;QAC3C,CAAC;gBAAS,CAAC;YACT,IAAI,KAAK;gBAAE,YAAY,CAAC,KAAK,CAAC,CAAC;QACjC,CAAC;IACH,CAAC;IAED,2EAA2E;IAC3E,qBAAqB,CAAC,MAAuB,EAAE,WAAoB;QACjE,MAAM,QAAQ,GAA+B;YAC3C,WAAW,EAAE,WAAW,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,WAAW,EAAE;YAChE,MAAM;SACP,CAAC;QACF,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,mBAAmB,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC5E,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,mBAAmB,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;IAClF,CAAC;IAED,eAAe;QACb,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAA+B,CAAC;YAC1G,MAAM,CAAC,GAAG,GAAG,EAAE,MAAM,CAAC;YACtB,IAAI,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;gBACxC,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;YACzE,CAAC;YACD,OAAO;gBACL,GAAG,EAAE,IAAI;gBACT,MAAM,EAAE,CAAC,CAAC,MAAM;gBAChB,WAAW,EAAE,OAAO,GAAG,CAAC,WAAW,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI;gBACzE,OAAO,EAAE;oBACP,qBAAqB,EAAE,CAAC,CAAC,qBAAqB,EAAE,MAAM,IAAI,CAAC;oBAC3D,iBAAiB,EAAE,CAAC,CAAC,iBAAiB,EAAE,MAAM,IAAI,CAAC;oBACnD,kBAAkB,EAAE,CAAC,CAAC,kBAAkB,EAAE,MAAM,IAAI,CAAC;oBACrD,YAAY,EAAE,CAAC,CAAC,YAAY,EAAE,MAAM,IAAI,CAAC;iBAC1C;aACF,CAAC;QACJ,CAAC;QAAC,MAAM,CAAC,CAAC,4FAA4F;YACpG,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACzE,CAAC;IACH,CAAC;IAED,YAAY;QACV,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAC/C,MAAM,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,MAAM,CAAC;QAC3C,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;QAC3E,MAAM,KAAK,GAAG,UAAU,KAAK,IAAI;YAC/B,CAAC,CAAC,IAAI;YACN,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC;QAC5D,OAAO,EAAE,GAAG,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;IACxC,CAAC;IAED;;;;;;OAMG;IACH,KAAK,CAAC,aAAa;QACjB,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,cAAc,EAAE,CAAC;YAC3B,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,wFAAwF,EAAE,CAAC;QACzH,CAAC;QACD,IAAI,IAAI,CAAC,iBAAiB,EAAE,CAAC;YAC3B,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,wCAAwC,IAAI,CAAC,iBAAiB,CAAC,EAAE,YAAY,IAAI,CAAC,iBAAiB,CAAC,SAAS,6CAA6C,EAAE,CAAC;QAC3L,CAAC;QACD,IAAI,CAAC,iBAAiB,GAAG,EAAE,EAAE,EAAE,aAAa,EAAE,SAAS,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC;QAChG,IAAI,MAAoB,CAAC;QACzB,IAAI,CAAC;YACH,MAAM,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,cAAc,EAAE,EAAE,aAAa,CAAC,CAAC;QAC1E,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,wBAAwB,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QAC3G,CAAC;gBAAS,CAAC;YACT,IAAI,CAAC,iBAAiB,GAAG,IAAI,CAAC;QAChC,CAAC;QACD,MAAM,EAAE,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;QAChD,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,YAAY,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QAC9C,MAAM,WAAW,GAAG,MAAM,CAAC,sBAAsB,CAAC,MAAM,GAAG,MAAM,CAAC,kBAAkB,CAAC,MAAM,CAAC;QAC5F,OAAO;YACL,EAAE,EAAE,IAAI;YACR,IAAI,EAAE,EAAE,EAAE,EAAE,gBAAgB,EAAE,MAAM,CAAC,gBAAgB,EAAE,WAAW,EAAE,SAAS,EAAE,WAAW,GAAG,CAAC,EAAE;YAChG,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,CAAC;SACpC,CAAC;IACJ,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,mBAAmB;QACvB,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,eAAe,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,sBAAsB,EAAE,CAAC;YAC9D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,6FAA6F,EAAE,CAAC;QAC9H,CAAC;QACD,IAAI,IAAI,CAAC,iBAAiB,EAAE,CAAC;YAC3B,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,wCAAwC,IAAI,CAAC,iBAAiB,CAAC,EAAE,YAAY,IAAI,CAAC,iBAAiB,CAAC,SAAS,6CAA6C,EAAE,CAAC;QAC3L,CAAC;QACD,IAAI,CAAC,iBAAiB,GAAG,EAAE,EAAE,EAAE,gBAAgB,EAAE,SAAS,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC;QACnG,IAAI,MAAuB,CAAC;QAC5B,IAAI,CAAC;YACH,MAAM,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,eAAe,EAAE,EAAE,gBAAgB,CAAC,CAAC;QAC9E,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,0BAA0B,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QAC7G,CAAC;gBAAS,CAAC;YACT,IAAI,CAAC,iBAAiB,GAAG,IAAI,CAAC;QAChC,CAAC;QACD,MAAM,WAAW,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;QACzD,MAAM,QAAQ,GAA4B,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC;QACnF,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,sBAAsB,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/E,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,sBAAsB,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;QACnF,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;IAC3C,CAAC;IAED,kBAAkB;QAChB,MAAM,KAAK,GAAuB,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAC;QAC3H,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,sBAAsB;YAAE,OAAO,KAAK,CAAC;QACjD,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,sBAAsB,EAAE,MAAM,CAAC,CAA4B,CAAC;YAC1G,MAAM,CAAC,GAAG,GAAG,EAAE,MAAM,CAAC;YACtB,IAAI,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,MAAM,KAAK,SAAS;gBAAE,OAAO,KAAK,CAAC;YACtD,OAAO;gBACL,GAAG,EAAE,IAAI;gBACT,MAAM,EAAE,CAAC,CAAC,MAAM;gBAChB,WAAW,EAAE,OAAO,GAAG,CAAC,WAAW,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI;gBACzE,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,IAAI;gBAC5B,gBAAgB,EAAE,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI;aACxE,CAAC;QACJ,CAAC;QAAC,MAAM,CAAC,CAAC,8GAA8G;YACtH,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC;IAED,uFAAuF;IACvF,MAAM;QACJ,MAAM,SAAS,GAAG,IAAI,CAAC,eAAe,EAAE,CAAC;QACzC,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;QACnC,OAAO;YACL,0FAA0F;YAC1F,KAAK,EAAE,SAAS,CAAC,MAAM,IAAI,MAAM,CAAC,OAAO,IAAI,CAAC,MAAM,CAAC,KAAK;YAC1D,IAAI,EAAE,uBAAuB;YAC7B,SAAS;YACT,MAAM;YACN,YAAY,EAAE,IAAI,CAAC,kBAAkB,EAAE;SACxC,CAAC;IACJ,CAAC;CACF"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerStreamProxy — phase 1 of Pool Dashboard Streaming
|
|
3
|
+
* (POOL-DASHBOARD-STREAM-SPEC §2.2, the scalability core).
|
|
4
|
+
*
|
|
5
|
+
* ONE multiplexed upstream connection per PEER machine, shared by every local
|
|
6
|
+
* dashboard client and every remote session on that peer. The local
|
|
7
|
+
* WebSocketManager (phase 2) owns local clients and the browser WS; this class
|
|
8
|
+
* owns ONLY the single upstream link and the bookkeeping that makes it correct:
|
|
9
|
+
*
|
|
10
|
+
* - reference-counted subscriptions (per (session) → set of local clientIds);
|
|
11
|
+
* - a 60s idle grace after the last unsubscribe, cancelled if a subscribe
|
|
12
|
+
* arrives during it (no thrash opening/closing on a flapping client);
|
|
13
|
+
* - ONE bounded reconnect on an upstream drop, resubscribing every current
|
|
14
|
+
* subscription; a reconnect that does not open within reconnectTimeoutMs, or
|
|
15
|
+
* a second drop, surfaces `machine-unreachable` (P19 — never a reconnect
|
|
16
|
+
* storm);
|
|
17
|
+
* - subscriptions that arrive mid-(re)connect are QUEUED and merged into the
|
|
18
|
+
* resubscribe batch the moment the link opens — never lost;
|
|
19
|
+
* - the peer URL is re-resolved on every subscribe; a changed URL tears the
|
|
20
|
+
* old link down and opens a new one (no stale-URL duplicate links).
|
|
21
|
+
*
|
|
22
|
+
* Everything external is injected (transport, timers, clock) so the whole state
|
|
23
|
+
* machine is deterministically unit-testable without real sockets or wall time.
|
|
24
|
+
* This module performs NO tmux capture and NO local fan-out polling — capture
|
|
25
|
+
* happens only on the owning machine; fan-out to local clients is the injected
|
|
26
|
+
* `onFrameToClients`. (TAP POINT, ops#1: remote subs never enter the local
|
|
27
|
+
* polling loop.)
|
|
28
|
+
*/
|
|
29
|
+
export type ProxyState = 'idle' | 'connecting' | 'active' | 'idle-scheduled' | 'closing' | 'closed';
|
|
30
|
+
/** A live upstream connection handle (one peer's /ws/pool-stream). */
|
|
31
|
+
export interface UpstreamTransport {
|
|
32
|
+
/** Send a frame to the peer (subscribe/unsubscribe/input/key/history). */
|
|
33
|
+
send(frame: Record<string, unknown>): void;
|
|
34
|
+
/** Tear the connection down. Idempotent. */
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
/** Handlers the proxy gives the transport at connect time. */
|
|
38
|
+
export interface UpstreamHandlers {
|
|
39
|
+
/** The link opened and is ready to carry frames. */
|
|
40
|
+
onOpen(): void;
|
|
41
|
+
/** A frame arrived from the peer (output/history/session_ended/error/…). */
|
|
42
|
+
onFrame(frame: Record<string, unknown>): void;
|
|
43
|
+
/** The link closed (clean or error). */
|
|
44
|
+
onClose(): void;
|
|
45
|
+
}
|
|
46
|
+
export type TimerHandle = {
|
|
47
|
+
__brand: 'PeerStreamProxyTimer';
|
|
48
|
+
};
|
|
49
|
+
export interface PeerStreamProxyDeps {
|
|
50
|
+
peerMachineId: string;
|
|
51
|
+
/** Resolve the peer's CURRENT url (re-read each subscribe for URL-change detection). */
|
|
52
|
+
resolveUrl: () => string | null;
|
|
53
|
+
/** Open an upstream link to `url`; the returned transport drives `handlers`. */
|
|
54
|
+
connect: (url: string, handlers: UpstreamHandlers) => UpstreamTransport;
|
|
55
|
+
/** Fan a peer frame out to every LOCAL client subscribed to (session). */
|
|
56
|
+
onFrameToClients: (session: string, frame: Record<string, unknown>) => void;
|
|
57
|
+
/** Surface an error to every local client subscribed to (session). */
|
|
58
|
+
onError: (session: string, code: 'peer-stream-lost' | 'machine-unreachable', detail?: string) => void;
|
|
59
|
+
now: () => number;
|
|
60
|
+
setTimer: (ms: number, fn: () => void) => TimerHandle;
|
|
61
|
+
clearTimer: (t: TimerHandle) => void;
|
|
62
|
+
/** Idle grace before closing an unsubscribed link (default 60_000). */
|
|
63
|
+
idleGraceMs?: number;
|
|
64
|
+
/** Reconnect open deadline (default 10_000). */
|
|
65
|
+
reconnectTimeoutMs?: number;
|
|
66
|
+
logger?: (line: string) => void;
|
|
67
|
+
}
|
|
68
|
+
export declare class PeerStreamProxy {
|
|
69
|
+
private readonly d;
|
|
70
|
+
private state;
|
|
71
|
+
private transport;
|
|
72
|
+
private connectedUrl;
|
|
73
|
+
/** session → set of local clientIds subscribed to it. THE source of truth:
|
|
74
|
+
* on every (re)open we resubscribe from this map, so a subscribe that
|
|
75
|
+
* arrives mid-connect is never lost and a mid-connect unsubscribe is never
|
|
76
|
+
* replayed. */
|
|
77
|
+
private readonly subs;
|
|
78
|
+
private idleTimer;
|
|
79
|
+
private reconnectTimer;
|
|
80
|
+
private reconnectUsed;
|
|
81
|
+
constructor(d: PeerStreamProxyDeps);
|
|
82
|
+
get currentState(): ProxyState;
|
|
83
|
+
/** Total live subscriptions across all sessions (refcount). */
|
|
84
|
+
get refCount(): number;
|
|
85
|
+
/** Snapshot of session → clientIds (tests / introspection). */
|
|
86
|
+
sessionsView(): Record<string, string[]>;
|
|
87
|
+
private log;
|
|
88
|
+
/** A local client subscribes to a remote session on this peer. */
|
|
89
|
+
subscribe(session: string, clientId: string): void;
|
|
90
|
+
/** A local client unsubscribes (or disconnects). */
|
|
91
|
+
unsubscribe(session: string, clientId: string): void;
|
|
92
|
+
/** Forward an input/key frame for a session (caller already gated allowRemoteInput). */
|
|
93
|
+
relayInput(frame: Record<string, unknown>): void;
|
|
94
|
+
private openFresh;
|
|
95
|
+
private onUpstreamOpen;
|
|
96
|
+
private onUpstreamFrame;
|
|
97
|
+
private onUpstreamClose;
|
|
98
|
+
private scheduleIdleClose;
|
|
99
|
+
private failAll;
|
|
100
|
+
private teardownTransport;
|
|
101
|
+
/** Force-close (peer removed, server shutdown). Idempotent. */
|
|
102
|
+
close(): void;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=PeerStreamProxy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PeerStreamProxy.d.ts","sourceRoot":"","sources":["../../src/server/PeerStreamProxy.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerStreamProxy — phase 1 of Pool Dashboard Streaming
|
|
3
|
+
* (POOL-DASHBOARD-STREAM-SPEC §2.2, the scalability core).
|
|
4
|
+
*
|
|
5
|
+
* ONE multiplexed upstream connection per PEER machine, shared by every local
|
|
6
|
+
* dashboard client and every remote session on that peer. The local
|
|
7
|
+
* WebSocketManager (phase 2) owns local clients and the browser WS; this class
|
|
8
|
+
* owns ONLY the single upstream link and the bookkeeping that makes it correct:
|
|
9
|
+
*
|
|
10
|
+
* - reference-counted subscriptions (per (session) → set of local clientIds);
|
|
11
|
+
* - a 60s idle grace after the last unsubscribe, cancelled if a subscribe
|
|
12
|
+
* arrives during it (no thrash opening/closing on a flapping client);
|
|
13
|
+
* - ONE bounded reconnect on an upstream drop, resubscribing every current
|
|
14
|
+
* subscription; a reconnect that does not open within reconnectTimeoutMs, or
|
|
15
|
+
* a second drop, surfaces `machine-unreachable` (P19 — never a reconnect
|
|
16
|
+
* storm);
|
|
17
|
+
* - subscriptions that arrive mid-(re)connect are QUEUED and merged into the
|
|
18
|
+
* resubscribe batch the moment the link opens — never lost;
|
|
19
|
+
* - the peer URL is re-resolved on every subscribe; a changed URL tears the
|
|
20
|
+
* old link down and opens a new one (no stale-URL duplicate links).
|
|
21
|
+
*
|
|
22
|
+
* Everything external is injected (transport, timers, clock) so the whole state
|
|
23
|
+
* machine is deterministically unit-testable without real sockets or wall time.
|
|
24
|
+
* This module performs NO tmux capture and NO local fan-out polling — capture
|
|
25
|
+
* happens only on the owning machine; fan-out to local clients is the injected
|
|
26
|
+
* `onFrameToClients`. (TAP POINT, ops#1: remote subs never enter the local
|
|
27
|
+
* polling loop.)
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_IDLE_GRACE_MS = 60_000;
|
|
30
|
+
const DEFAULT_RECONNECT_TIMEOUT_MS = 10_000;
|
|
31
|
+
export class PeerStreamProxy {
|
|
32
|
+
d;
|
|
33
|
+
state = 'idle';
|
|
34
|
+
transport = null;
|
|
35
|
+
connectedUrl = null;
|
|
36
|
+
/** session → set of local clientIds subscribed to it. THE source of truth:
|
|
37
|
+
* on every (re)open we resubscribe from this map, so a subscribe that
|
|
38
|
+
* arrives mid-connect is never lost and a mid-connect unsubscribe is never
|
|
39
|
+
* replayed. */
|
|
40
|
+
subs = new Map();
|
|
41
|
+
idleTimer = null;
|
|
42
|
+
reconnectTimer = null;
|
|
43
|
+
reconnectUsed = false;
|
|
44
|
+
constructor(d) {
|
|
45
|
+
this.d = d;
|
|
46
|
+
}
|
|
47
|
+
get currentState() {
|
|
48
|
+
return this.state;
|
|
49
|
+
}
|
|
50
|
+
/** Total live subscriptions across all sessions (refcount). */
|
|
51
|
+
get refCount() {
|
|
52
|
+
let n = 0;
|
|
53
|
+
for (const set of this.subs.values())
|
|
54
|
+
n += set.size;
|
|
55
|
+
return n;
|
|
56
|
+
}
|
|
57
|
+
/** Snapshot of session → clientIds (tests / introspection). */
|
|
58
|
+
sessionsView() {
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const [s, set] of this.subs)
|
|
61
|
+
out[s] = [...set];
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
log(line) {
|
|
65
|
+
this.d.logger?.(`[peer-stream-proxy ${this.d.peerMachineId}] ${line}`);
|
|
66
|
+
}
|
|
67
|
+
// ── subscribe / unsubscribe (refcounted) ───────────────────────────────
|
|
68
|
+
/** A local client subscribes to a remote session on this peer. */
|
|
69
|
+
subscribe(session, clientId) {
|
|
70
|
+
if (this.state === 'closed') {
|
|
71
|
+
this.log('subscribe after close — ignored');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Cancel a pending idle close — we have demand again.
|
|
75
|
+
if (this.idleTimer) {
|
|
76
|
+
this.d.clearTimer(this.idleTimer);
|
|
77
|
+
this.idleTimer = null;
|
|
78
|
+
if (this.state === 'idle-scheduled')
|
|
79
|
+
this.state = 'active';
|
|
80
|
+
}
|
|
81
|
+
let set = this.subs.get(session);
|
|
82
|
+
if (!set) {
|
|
83
|
+
set = new Set();
|
|
84
|
+
this.subs.set(session, set);
|
|
85
|
+
}
|
|
86
|
+
const firstForSession = set.size === 0;
|
|
87
|
+
set.add(clientId);
|
|
88
|
+
// URL re-resolution (stale-URL guard): if the peer moved, reconnect fresh.
|
|
89
|
+
const url = this.d.resolveUrl();
|
|
90
|
+
if (this.state === 'active' && this.connectedUrl && url && url !== this.connectedUrl) {
|
|
91
|
+
this.log(`peer url changed (${this.connectedUrl} → ${url}); reconnecting`);
|
|
92
|
+
this.teardownTransport();
|
|
93
|
+
this.openFresh(url);
|
|
94
|
+
return; // openFresh replays ALL current subs (incl. this one) on open
|
|
95
|
+
}
|
|
96
|
+
if (this.state === 'idle' || this.state === 'closing') {
|
|
97
|
+
// No link yet → open one; openFresh flushes all subs on open.
|
|
98
|
+
if (!url) {
|
|
99
|
+
// No reachable URL — honest failure, drop the just-added sub for it.
|
|
100
|
+
set.delete(clientId);
|
|
101
|
+
if (set.size === 0)
|
|
102
|
+
this.subs.delete(session);
|
|
103
|
+
this.d.onError(session, 'machine-unreachable', 'no url for peer');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.openFresh(url);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (this.state === 'active') {
|
|
110
|
+
// Link is live → forward only when this is the first client for the
|
|
111
|
+
// session (the upstream is already streaming it for any later client).
|
|
112
|
+
if (firstForSession)
|
|
113
|
+
this.transport?.send({ type: 'subscribe', session });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// connecting → the sub is now in `subs`; onUpstreamOpen resubscribes from
|
|
117
|
+
// `subs`, so it is automatically included in the batch (never lost).
|
|
118
|
+
}
|
|
119
|
+
/** A local client unsubscribes (or disconnects). */
|
|
120
|
+
unsubscribe(session, clientId) {
|
|
121
|
+
const set = this.subs.get(session);
|
|
122
|
+
if (!set)
|
|
123
|
+
return;
|
|
124
|
+
set.delete(clientId);
|
|
125
|
+
if (set.size === 0) {
|
|
126
|
+
this.subs.delete(session);
|
|
127
|
+
if (this.state === 'active')
|
|
128
|
+
this.transport?.send({ type: 'unsubscribe', session });
|
|
129
|
+
}
|
|
130
|
+
if (this.refCount === 0)
|
|
131
|
+
this.scheduleIdleClose();
|
|
132
|
+
}
|
|
133
|
+
/** Forward an input/key frame for a session (caller already gated allowRemoteInput). */
|
|
134
|
+
relayInput(frame) {
|
|
135
|
+
if (this.state === 'active')
|
|
136
|
+
this.transport?.send(frame);
|
|
137
|
+
// Not active → input is dropped (caller's terminal shows the live state);
|
|
138
|
+
// we never queue keystrokes across a reconnect (stale input is worse than none).
|
|
139
|
+
}
|
|
140
|
+
// ── connection lifecycle ───────────────────────────────────────────────
|
|
141
|
+
openFresh(url, reconnect = false) {
|
|
142
|
+
// A user-initiated open resets the one-shot reconnect budget; a reconnect
|
|
143
|
+
// leaves `reconnectUsed` true so a further drop → machine-unreachable.
|
|
144
|
+
if (!reconnect)
|
|
145
|
+
this.reconnectUsed = false;
|
|
146
|
+
this.state = 'connecting';
|
|
147
|
+
this.connectedUrl = url;
|
|
148
|
+
const handlers = {
|
|
149
|
+
onOpen: () => this.onUpstreamOpen(),
|
|
150
|
+
onFrame: (f) => this.onUpstreamFrame(f),
|
|
151
|
+
onClose: () => this.onUpstreamClose(),
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
this.transport = this.d.connect(url, handlers);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
this.log(`connect threw: ${e?.message ?? e}`);
|
|
158
|
+
this.onUpstreamClose();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
onUpstreamOpen() {
|
|
162
|
+
if (this.state === 'closed') {
|
|
163
|
+
this.transport?.close();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.state = 'active';
|
|
167
|
+
if (this.reconnectTimer) {
|
|
168
|
+
this.d.clearTimer(this.reconnectTimer);
|
|
169
|
+
this.reconnectTimer = null;
|
|
170
|
+
}
|
|
171
|
+
// Resubscribe from `subs` (the source of truth) — this naturally includes
|
|
172
|
+
// any subscribe that arrived mid-connect and excludes any mid-connect
|
|
173
|
+
// unsubscribe. No stale snapshot to lose.
|
|
174
|
+
for (const session of this.subs.keys())
|
|
175
|
+
this.transport?.send({ type: 'subscribe', session });
|
|
176
|
+
// If nothing is subscribed anymore (all unsubscribed during connect), idle-close.
|
|
177
|
+
if (this.refCount === 0)
|
|
178
|
+
this.scheduleIdleClose();
|
|
179
|
+
}
|
|
180
|
+
onUpstreamFrame(frame) {
|
|
181
|
+
const session = typeof frame.session === 'string' ? frame.session : null;
|
|
182
|
+
if (!session)
|
|
183
|
+
return; // peer frames without a session are not fan-out-able
|
|
184
|
+
// Only fan out to sessions we still have local subscribers for.
|
|
185
|
+
if (!this.subs.has(session))
|
|
186
|
+
return;
|
|
187
|
+
this.d.onFrameToClients(session, frame);
|
|
188
|
+
}
|
|
189
|
+
onUpstreamClose() {
|
|
190
|
+
if (this.state === 'closed' || this.state === 'closing') {
|
|
191
|
+
this.state = 'closed';
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.transport = null;
|
|
195
|
+
// Nothing subscribed → just go idle, no reconnect.
|
|
196
|
+
if (this.refCount === 0) {
|
|
197
|
+
this.state = 'idle';
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (this.reconnectUsed) {
|
|
201
|
+
// Second failure → declare the peer unreachable for every affected session.
|
|
202
|
+
this.log('second upstream drop → machine-unreachable');
|
|
203
|
+
this.failAll('machine-unreachable', 'peer unreachable after reconnect');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// First drop → tell clients, attempt ONE bounded reconnect.
|
|
207
|
+
for (const session of this.subs.keys())
|
|
208
|
+
this.d.onError(session, 'peer-stream-lost', 'upstream dropped');
|
|
209
|
+
this.reconnectUsed = true;
|
|
210
|
+
const url = this.d.resolveUrl();
|
|
211
|
+
if (!url) {
|
|
212
|
+
this.failAll('machine-unreachable', 'no url on reconnect');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
this.openFresh(url, true);
|
|
216
|
+
// Bound the reconnect: if it doesn't open in time, declare unreachable.
|
|
217
|
+
this.reconnectTimer = this.d.setTimer(this.d.reconnectTimeoutMs ?? DEFAULT_RECONNECT_TIMEOUT_MS, () => {
|
|
218
|
+
this.reconnectTimer = null;
|
|
219
|
+
if (this.state !== 'active') {
|
|
220
|
+
this.log('reconnect timed out → machine-unreachable');
|
|
221
|
+
this.teardownTransport();
|
|
222
|
+
this.failAll('machine-unreachable', 'reconnect timed out');
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
scheduleIdleClose() {
|
|
227
|
+
if (this.idleTimer || this.state === 'closed')
|
|
228
|
+
return;
|
|
229
|
+
if (this.state === 'active')
|
|
230
|
+
this.state = 'idle-scheduled';
|
|
231
|
+
this.idleTimer = this.d.setTimer(this.d.idleGraceMs ?? DEFAULT_IDLE_GRACE_MS, () => {
|
|
232
|
+
this.idleTimer = null;
|
|
233
|
+
if (this.refCount === 0) {
|
|
234
|
+
this.log('idle grace elapsed → closing');
|
|
235
|
+
this.teardownTransport();
|
|
236
|
+
this.state = 'closed';
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
failAll(code, detail) {
|
|
241
|
+
for (const session of this.subs.keys())
|
|
242
|
+
this.d.onError(session, code, detail);
|
|
243
|
+
this.subs.clear();
|
|
244
|
+
this.teardownTransport();
|
|
245
|
+
this.state = 'closed';
|
|
246
|
+
}
|
|
247
|
+
teardownTransport() {
|
|
248
|
+
if (this.reconnectTimer) {
|
|
249
|
+
this.d.clearTimer(this.reconnectTimer);
|
|
250
|
+
this.reconnectTimer = null;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
this.transport?.close();
|
|
254
|
+
}
|
|
255
|
+
catch { /* @silent-fallback-ok: closing an already-dead upstream is best-effort */ }
|
|
256
|
+
this.transport = null;
|
|
257
|
+
this.connectedUrl = null;
|
|
258
|
+
}
|
|
259
|
+
/** Force-close (peer removed, server shutdown). Idempotent. */
|
|
260
|
+
close() {
|
|
261
|
+
if (this.idleTimer) {
|
|
262
|
+
this.d.clearTimer(this.idleTimer);
|
|
263
|
+
this.idleTimer = null;
|
|
264
|
+
}
|
|
265
|
+
this.subs.clear();
|
|
266
|
+
this.teardownTransport();
|
|
267
|
+
this.state = 'closed';
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
//# sourceMappingURL=PeerStreamProxy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PeerStreamProxy.js","sourceRoot":"","sources":["../../src/server/PeerStreamProxy.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-06-07T00:
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-06-07T00:51:44.855Z",
|
|
5
|
+
"instarVersion": "1.3.389",
|
|
6
6
|
"entryCount": 199,
|
|
7
7
|
"entries": {
|
|
8
8
|
"hook:session-start": {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
Fixed a liveness bug in the cutover-readiness checker (#948): `CutoverReadiness`'s single-flight lock over the live source fetch (used by both the parity pass and the import dry-run) could be held **indefinitely** if the injected fetch never settled. In production the lock stuck for ~85 minutes (`logs/server.log`), so every subsequent parity pass and dry-run was refused with "live source fetch already in flight" — the parity-pass feeder kept no-opping and the cutover parity window went stale and never refreshed.
|
|
9
|
+
|
|
10
|
+
`src/feedback-factory/cutoverReadiness.ts` now wraps the awaited live fetch in a wall-clock **max-hold guard** (`withMaxHold`, new optional `maxLiveFetchMs` dep, default 12 minutes) at the lock boundary. The lock is released in a `finally`, but a `finally` can't run while the awaited promise never settles — so the guard races the fetch against a timer and guarantees the lock releases within a bounded time regardless of *why* the fetch stalled (an `AbortSignal.timeout` that didn't fire on a stalled socket, or the un-timed compare step). The default sits comfortably above `HttpParitySource`'s 600s total budget, so it only fires when that inner budget has genuinely failed — never on a slow-but-working pass.
|
|
11
|
+
|
|
12
|
+
## What to Tell Your User
|
|
13
|
+
|
|
14
|
+
If you were ever told the feedback-migration "cutover readiness" check was stuck or its parity window had gone stale, this fixes the underlying cause: a single slow/hung check could jam the checker until the next restart. Now a check that takes too long is abandoned cleanly and the next one runs on schedule. It ships on by default with a safe 12-minute backstop and needs no configuration.
|
|
15
|
+
|
|
16
|
+
## Summary of New Capabilities
|
|
17
|
+
|
|
18
|
+
- A wall-clock max-hold backstop on the cutover-readiness live-fetch single-flight lock — a stalled parity pass or import dry-run can no longer hang the checker indefinitely.
|
|
19
|
+
- New optional `maxLiveFetchMs` knob (default 12m); the default IS the fix, so existing agents get it automatically on update with no config change.
|
|
20
|
+
|
|
21
|
+
## Evidence
|
|
22
|
+
|
|
23
|
+
Before: a single stalled live fetch held the single-flight lock until the next server restart (~85 min observed), starving the parity feeder. After: the lock releases within `maxLiveFetchMs` and the next sweep retries. Pinned by a Tier-1 regression (`tests/unit/cutover-readiness.test.ts`): a `runParityCheck` (and `runImportDryRun`) that never settles now returns `ok:false` AND a subsequent pass on the same instance is NOT refused as in-flight — it runs and records normally. 22 cutover-readiness unit tests green (2 new + 20 existing through the new path); `tsc --noEmit` clean.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
First building block of the single-dashboard cross-machine streaming feature
|
|
9
|
+
(POOL-DASHBOARD-STREAM-SPEC): the `PeerStreamProxy` — one multiplexed upstream
|
|
10
|
+
connection per peer machine that the dashboard's WebSocket layer will use
|
|
11
|
+
(phase 2) to relay a remote session's terminal stream. This phase ships the
|
|
12
|
+
state machine ONLY; it is not yet wired to the live WebSocketManager, so it
|
|
13
|
+
changes no user-visible behavior on its own.
|
|
14
|
+
|
|
15
|
+
The proxy is the scalability + correctness core the reviewers stressed:
|
|
16
|
+
reference-counted subscriptions, a 60s idle grace (cancelled if demand
|
|
17
|
+
returns), ONE bounded reconnect on a drop with full resubscribe (a second drop
|
|
18
|
+
or a reconnect timeout → machine-unreachable, never a reconnect storm),
|
|
19
|
+
mid-reconnect subscribes merged from the source-of-truth subscription map (never
|
|
20
|
+
lost), and peer-URL-change detection (stale-URL links are torn down). Capture
|
|
21
|
+
stays on the owning machine; this only relays — so fleet cost is
|
|
22
|
+
O(subscriptions), not O(sessions × clients × peers).
|
|
23
|
+
|
|
24
|
+
## What to Tell Your User
|
|
25
|
+
|
|
26
|
+
Nothing yet — this is internal plumbing. The visible result (click any
|
|
27
|
+
machine's session and it streams) lands when phases 2–3 wire it to the
|
|
28
|
+
dashboard.
|
|
29
|
+
|
|
30
|
+
- audience: agent-only
|
|
31
|
+
- maturity: preview
|
|
32
|
+
|
|
33
|
+
## Summary of New Capabilities
|
|
34
|
+
|
|
35
|
+
- `src/server/PeerStreamProxy.ts` — the per-peer upstream relay state machine,
|
|
36
|
+
fully transport/timer/clock-injected (no live wiring yet).
|
|
37
|
+
|
|
38
|
+
## Evidence
|
|
39
|
+
|
|
40
|
+
- `tests/unit/PeerStreamProxy.test.ts` (13 tests, deterministic): open+multiplex
|
|
41
|
+
(one upstream, no double-subscribe for a second client), idle-close +
|
|
42
|
+
mid-grace reactivate, bounded reconnect + resubscribe, second-drop →
|
|
43
|
+
unreachable, reconnect-timeout → unreachable, mid-reconnect subscribe merged,
|
|
44
|
+
url-change reopen, no-url → machine-unreachable, input-relay-only-when-active,
|
|
45
|
+
idempotent close.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Side-Effects Review — Cutover-readiness live-fetch max-hold (lock-boundary backstop, #948)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `cutover-parity-lock-maxhold`
|
|
4
|
+
**Date:** `2026-06-07`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `self-review (single-file behavioral hardening; both branches — stalled and healthy — covered by Tier-1 tests over the real CutoverReadiness)`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Fixes #948: `CutoverReadiness`'s single-flight lock (`liveFetchInFlight`) could be held
|
|
11
|
+
indefinitely when an injected live fetch (parity pass or import dry-run) never settled —
|
|
12
|
+
observed ~85 minutes in production (`logs/server.log`), permanently refusing every
|
|
13
|
+
subsequent parity pass + dry-run with "live source fetch already in flight" and starving
|
|
14
|
+
the parity feeder (so the cutover window went stale and never refreshed).
|
|
15
|
+
|
|
16
|
+
Root: the lock is released in a `finally`, which is correct — but a `finally` cannot run
|
|
17
|
+
while the awaited promise never settles. `HttpParitySource.prepare()` has per-page +
|
|
18
|
+
between-page bounds, so the stall is either an `AbortSignal.timeout` that doesn't fire on a
|
|
19
|
+
particular socket stall, or the un-timed compare step. Rather than chase the sub-cause, the
|
|
20
|
+
fix adds a wall-clock **max-hold guard at the lock boundary** (`withMaxHold`, default 12m):
|
|
21
|
+
the awaited fetch is raced against a timer, so the lock ALWAYS releases within a bounded
|
|
22
|
+
time regardless of why the fetch stalled. Structure > willpower — the lock-holder enforces
|
|
23
|
+
its own max-hold, independent of the injected fetch's internal timeouts.
|
|
24
|
+
|
|
25
|
+
Files: `src/feedback-factory/cutoverReadiness.ts` (+ `tests/unit/cutover-readiness.test.ts`).
|
|
26
|
+
|
|
27
|
+
## Decision-point inventory
|
|
28
|
+
|
|
29
|
+
- `withMaxHold` — add — a private timeout-race helper. NO decision authority: it only
|
|
30
|
+
bounds how long the single-flight lock can be held; it never decides parity/integrity.
|
|
31
|
+
- `maxLiveFetchMs` config (default 12m) — add — a backstop dial, set comfortably above
|
|
32
|
+
`HttpParitySource`'s 600s total budget so it only fires when that inner budget genuinely
|
|
33
|
+
failed (never a false-abort of a slow-but-working pass).
|
|
34
|
+
|
|
35
|
+
## 1. Over-block
|
|
36
|
+
|
|
37
|
+
The only thing the max-hold can "block" is a live fetch that exceeds 12 minutes. A healthy
|
|
38
|
+
parity pass (~15s/page, 600s inner budget) is far under that, so a working pass is never
|
|
39
|
+
aborted. Default chosen above the inner budget specifically to avoid over-block.
|
|
40
|
+
|
|
41
|
+
## 2. Under-block
|
|
42
|
+
|
|
43
|
+
If a stall somehow resolved at exactly the budget edge, the worst case is one wasted sweep
|
|
44
|
+
(recorded nothing) and a retry next tick — strictly better than the pre-fix infinite hold.
|
|
45
|
+
|
|
46
|
+
## 3. Level-of-abstraction fit
|
|
47
|
+
|
|
48
|
+
Correct. The guard sits at the single-flight-lock boundary it protects, not inside the
|
|
49
|
+
fetch (which already has its own, evidently-insufficient, internal timeouts). It is a
|
|
50
|
+
backstop, not a replacement — the inner budgets still do the primary bounding.
|
|
51
|
+
|
|
52
|
+
## 4. Signal vs authority compliance
|
|
53
|
+
|
|
54
|
+
- [x] No block/allow authority. The change only ensures a lock releases; it never gates a
|
|
55
|
+
parity/integrity decision. `runParityPass` still records nothing on failure (a max-hold
|
|
56
|
+
timeout is a failed check = absence of evidence, exactly as a fetch error already was).
|
|
57
|
+
|
|
58
|
+
## 5. Interactions
|
|
59
|
+
|
|
60
|
+
- **Orphaned work:** when the max-hold fires, the abandoned fetch promise keeps running but
|
|
61
|
+
is harmless — its later settlement is ignored (control already left the `await`; the
|
|
62
|
+
`recordResult` line after it is never reached for the timed-out call), so it cannot
|
|
63
|
+
double-record or re-acquire the lock. It holds at most one connection until it aborts or
|
|
64
|
+
the process restarts — a bounded, acceptable cost versus an unreleasable lock.
|
|
65
|
+
- **Double-fire:** none — `Promise.race` settles once; `clearTimeout` in `finally` disarms
|
|
66
|
+
the timer on the happy path.
|
|
67
|
+
- **Clock:** the guard uses real `setTimeout` (NOT the injected `now()`), because the bug is
|
|
68
|
+
a promise that never settles in REAL time; an injected test clock must not disarm it.
|
|
69
|
+
|
|
70
|
+
## 6. External surfaces
|
|
71
|
+
|
|
72
|
+
- **Install base:** `maxLiveFetchMs` is a new OPTIONAL dep with a safe default → existing
|
|
73
|
+
agents get the backstop automatically on update, no config change required (Migration
|
|
74
|
+
Parity: the default IS the fix; nothing to migrate).
|
|
75
|
+
- **Persistent state:** none new.
|
|
76
|
+
- **Other agents / attention queue:** unchanged.
|
|
77
|
+
|
|
78
|
+
## 7. Rollback cost
|
|
79
|
+
|
|
80
|
+
Pure additive hardening. Back-out = revert the commit. No persistent-state cleanup; healthy
|
|
81
|
+
passes behave identically with or without the guard.
|
|
82
|
+
|
|
83
|
+
## Conclusion
|
|
84
|
+
|
|
85
|
+
A wall-clock backstop on a single-flight lock, proven by a Tier-1 regression that a
|
|
86
|
+
never-settling fetch now releases the lock (a later pass proceeds) instead of hanging it
|
|
87
|
+
for ~85 minutes. No new authority; safe default; auto-applies to existing agents.
|
|
88
|
+
|
|
89
|
+
## Evidence pointers
|
|
90
|
+
|
|
91
|
+
- Tier 1: `tests/unit/cutover-readiness.test.ts` — 2 new (#948 regression for both
|
|
92
|
+
`runParityPass` and `runImportDryRunPass`: a never-settling fetch hits the max-hold,
|
|
93
|
+
returns `ok:false`, and the lock releases so the next pass records normally) + the 20
|
|
94
|
+
existing cutover tests still green through the new code path (22 total).
|
|
95
|
+
- tsc `--noEmit` clean.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Side-Effects Review — Pool dashboard streaming phase 1 (PeerStreamProxy)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `dashboard-stream-phase1-proxy`
|
|
4
|
+
**Date:** `2026-06-06`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `not required`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
A NEW, self-contained module (`PeerStreamProxy`) — phase 1 of the converged
|
|
11
|
+
POOL-DASHBOARD-STREAM-SPEC. The per-peer upstream relay state machine, with all
|
|
12
|
+
external surfaces (transport, timers, clock) injected. NOT wired into the live
|
|
13
|
+
WebSocketManager or server boot in this phase, so it has zero runtime effect
|
|
14
|
+
until phase 2 consumes it.
|
|
15
|
+
|
|
16
|
+
## Decision-point inventory
|
|
17
|
+
|
|
18
|
+
The state machine's transitions: open / multiplex / idle-close / reactivate /
|
|
19
|
+
bounded-reconnect / second-drop-unreachable / reconnect-timeout / url-change /
|
|
20
|
+
no-url. Each is covered both-sides by a deterministic test.
|
|
21
|
+
|
|
22
|
+
## 1. Over-block
|
|
23
|
+
|
|
24
|
+
N/A — no gating; a relay. The conservative reconnect policy (one attempt per
|
|
25
|
+
episode, then machine-unreachable) could declare a flaky-but-recoverable peer
|
|
26
|
+
unreachable, but the user simply re-subscribes (re-click), which opens fresh
|
|
27
|
+
and resets the budget. Storm-proof beats persistent.
|
|
28
|
+
|
|
29
|
+
## 2. Under-block
|
|
30
|
+
|
|
31
|
+
Keystrokes are never queued across a (re)connect — stale input is worse than
|
|
32
|
+
none. Acceptable: the dashboard shows the live link state (phase 3) so the user
|
|
33
|
+
knows when input is live.
|
|
34
|
+
|
|
35
|
+
## 3. Level-of-abstraction fit
|
|
36
|
+
|
|
37
|
+
Pure module under src/server/, no I/O of its own. The WebSocketManager (phase 2)
|
|
38
|
+
owns local clients + the browser WS and injects the transport; this owns only
|
|
39
|
+
the single upstream link + its bookkeeping. Matches the P2 precedent of
|
|
40
|
+
shipping a tested building block before its consumer.
|
|
41
|
+
|
|
42
|
+
## 4. Signal vs authority compliance
|
|
43
|
+
|
|
44
|
+
**Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
|
|
45
|
+
|
|
46
|
+
No authority. A transport relay; all auth (ticket) + input gating
|
|
47
|
+
(allowRemoteInput) live in phase 2 at the WSManager/upgrade boundary per spec
|
|
48
|
+
§2.3.
|
|
49
|
+
|
|
50
|
+
## 5. Interactions
|
|
51
|
+
|
|
52
|
+
- WebSocketManager (phase 2): the future consumer; unaffected now (no import).
|
|
53
|
+
- Local polling loop: untouched — remote subs never enter it (TAP POINT). This
|
|
54
|
+
module does no tmux capture.
|
|
55
|
+
- P19 (No Unbounded Loops): the reconnect is a single bounded attempt; tests
|
|
56
|
+
assert the second-drop and timeout both terminate to machine-unreachable.
|
|
57
|
+
|
|
58
|
+
## 6. External surfaces
|
|
59
|
+
|
|
60
|
+
None in this phase. No routes, config, notifications, or wiring. A new source
|
|
61
|
+
file + its test only.
|