Commit baa3abd

Browse files
fix(relay): guard unlink teardown generation
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent b2203fa commit baa3abd

8 files changed

Lines changed: 318 additions & 27 deletions

‎infra/relay/src/environments/EnvironmentConnector.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ function makeAllocations(
198198
recordDns: ()=>Effect.die("unused"),
199199
markReady: ()=>Effect.die("unused"),
200200
claimRelease: ()=>Effect.die("unused"),
201+
claimDeprovision: ()=>Effect.die("unused"),
201202
remove: ()=>Effect.die("unused"),
203+
removeClaimed: ()=>Effect.die("unused"),
202204
};
203205
}
204206

‎infra/relay/src/environments/EnvironmentLinker.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function testLayer(input?: {
136136
revokeForEnvironmentPublicKey: ()=>Effect.succeed(false),
137137
}),
138138
Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider,{
139+
prepareDeprovision: ()=>Effect.succeed(null),
139140
deprovision: input?.deprovision??(()=>Effect.void),
140141
release: ()=>Effect.succeed(true),
141142
provision: ()=>

‎infra/relay/src/environments/ManagedEndpointAllocations.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
1010
ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb,db)));
1111

1212
describe("ManagedEndpointAllocations",()=>{
13+
it.effect("returns a claim generation only when deprovision wins the allocation CAS",()=>{
14+
letclaimedAt: string|undefined;
15+
constfakeDb={
16+
update: (table: unknown)=>{
17+
expect(table).toBe(relayManagedEndpointAllocations);
18+
return{
19+
set: (values: {readonlyupdatedAt: string})=>{
20+
claimedAt=values.updatedAt;
21+
return{
22+
where: ()=>({
23+
returning: ()=>Effect.succeed([{userId: "user-1"}]),
24+
}),
25+
};
26+
},
27+
};
28+
},
29+
}asunknownasRelayDb.RelayDb["Service"];
30+
31+
returnEffect.gen(function*(){
32+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
33+
constgeneration=yield*allocations.claimDeprovision({
34+
userId: "user-1",
35+
environmentId: "environment-1",
36+
updatedAt: "captured-generation",
37+
});
38+
39+
expect(generation).toBe(claimedAt);
40+
expect(generation).not.toBeNull();
41+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
42+
});
43+
44+
it.effect("does not remove an allocation superseded after a deprovision claim",()=>{
45+
constfakeDb={
46+
delete: (table: unknown)=>{
47+
expect(table).toBe(relayManagedEndpointAllocations);
48+
return{
49+
where: ()=>({
50+
returning: ()=>Effect.succeed([]),
51+
}),
52+
};
53+
},
54+
}asunknownasRelayDb.RelayDb["Service"];
55+
56+
returnEffect.gen(function*(){
57+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
58+
expect(
59+
yield*allocations.removeClaimed({
60+
userId: "user-1",
61+
environmentId: "environment-1",
62+
updatedAt: "outdated-claim-generation",
63+
}),
64+
).toBe(false);
65+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
66+
});
67+
1368
it.effect("retains database failures with allocation operation and identity",()=>{
1469
constcause=newError("database unavailable");
1570
constfakeDb={

‎infra/relay/src/environments/ManagedEndpointAllocations.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
5151
"record-dns",
5252
"mark-ready",
5353
"claim-release",
54+
"claim-deprovision",
5455
"remove",
56+
"remove-claimed",
5557
]),
5658
stage: Schema.Literals(["database-request","resolve-reservation"]),
5759
userId: Schema.String,
@@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey
9193
readonlyupdatedAt: string;
9294
}
9395

96+
interfaceClaimManagedEndpointDeprovisionInputextendsManagedEndpointAllocationKey{
97+
readonlyupdatedAt: string;
98+
}
99+
100+
interfaceRemoveClaimedManagedEndpointAllocationInputextendsManagedEndpointAllocationKey{
101+
readonlyupdatedAt: string;
102+
}
103+
94104
exportclassManagedEndpointAllocationsextendsContext.Service<
95105
ManagedEndpointAllocations,
96106
{
@@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service<
119129
readonlyclaimRelease: (
120130
input: ClaimManagedEndpointReleaseInput,
121131
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
132+
/**
133+
* Claims the complete allocation for teardown only if its generation still
134+
* matches the snapshot captured by the unlink operation.
135+
*
136+
* Returns the claim generation used by `removeClaimed`, or null when a
137+
* concurrent provision has already superseded the snapshot.
138+
*/
139+
readonlyclaimDeprovision: (
140+
input: ClaimManagedEndpointDeprovisionInput,
141+
)=>Effect.Effect<string|null,ManagedEndpointAllocationPersistenceError>;
122142
readonlyremove: (
123143
input: ManagedEndpointAllocationKey,
124144
)=>Effect.Effect<void,ManagedEndpointAllocationPersistenceError>;
145+
readonlyremoveClaimed: (
146+
input: RemoveClaimedManagedEndpointAllocationInput,
147+
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
125148
}
126149
>()("t3code-relay/environments/ManagedEndpointAllocations"){}
127150

@@ -321,6 +344,35 @@ export const make = Effect.gen(function* () {
321344
);
322345
returnclaimed;
323346
}),
347+
claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function*(
348+
input: ClaimManagedEndpointDeprovisionInput,
349+
){
350+
constclaimedAt=DateTime.formatIso(yield*DateTime.now);
351+
constclaimed=yield*db
352+
.update(relayManagedEndpointAllocations)
353+
.set({updatedAt: claimedAt})
354+
.where(
355+
and(
356+
whereAllocation(input),
357+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
358+
),
359+
)
360+
.returning({userId: relayManagedEndpointAllocations.userId})
361+
.pipe(
362+
Effect.map((rows)=>rows.length>0),
363+
Effect.mapError(
364+
(cause)=>
365+
newManagedEndpointAllocationPersistenceError({
366+
operation: "claim-deprovision",
367+
stage: "database-request",
368+
userId: input.userId,
369+
environmentId: input.environmentId,
370+
cause,
371+
}),
372+
),
373+
);
374+
returnclaimed ? claimedAt : null;
375+
}),
324376
remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function*(
325377
input: ManagedEndpointAllocationKey,
326378
){
@@ -339,6 +391,32 @@ export const make = Effect.gen(function* () {
339391
),
340392
);
341393
}),
394+
removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function*(
395+
input: RemoveClaimedManagedEndpointAllocationInput,
396+
){
397+
returnyield*db
398+
.delete(relayManagedEndpointAllocations)
399+
.where(
400+
and(
401+
whereAllocation(input),
402+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
403+
),
404+
)
405+
.returning({userId: relayManagedEndpointAllocations.userId})
406+
.pipe(
407+
Effect.map((rows)=>rows.length>0),
408+
Effect.mapError(
409+
(cause)=>
410+
newManagedEndpointAllocationPersistenceError({
411+
operation: "remove-claimed",
412+
stage: "database-request",
413+
userId: input.userId,
414+
environmentId: input.environmentId,
415+
cause,
416+
}),
417+
),
418+
);
419+
}),
342420
});
343421
});
344422

‎infra/relay/src/environments/ManagedEndpointProvider.test.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ interface AllocationCall {
5050
|"recordDns"
5151
|"markReady"
5252
|"claimRelease"
53-
|"remove";
53+
|"claimDeprovision"
54+
|"remove"
55+
|"removeClaimed";
5456
readonlyinput: unknown;
5557
}
5658

@@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) {
226228
mutate(allocationKey(input),(current)=>current);
227229
returntrue;
228230
}),
231+
claimDeprovision: (input)=>
232+
Effect.sync(()=>{
233+
calls.push({operation: "claimDeprovision", input });
234+
constallocation=allocations.get(allocationKey(input));
235+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
236+
returnnull;
237+
}
238+
mutate(allocationKey(input),(current)=>current);
239+
returnallocations.get(allocationKey(input))?.updatedAt??null;
240+
}),
229241
remove: (input)=>
230242
Effect.sync(()=>{
231243
calls.push({operation: "remove", input });
232244
allocations.delete(allocationKey(input));
233245
}),
246+
removeClaimed: (input)=>
247+
Effect.sync(()=>{
248+
calls.push({operation: "removeClaimed", input });
249+
constallocation=allocations.get(allocationKey(input));
250+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
251+
returnfalse;
252+
}
253+
allocations.delete(allocationKey(input));
254+
returntrue;
255+
}),
234256
});
235257
}
236258

@@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => {
774796
"recordDns",
775797
"markReady",
776798
"get",
777-
"remove",
799+
"claimDeprovision",
800+
"removeClaimed",
778801
]);
779802
}).pipe(Effect.provide(layer));
780803
},
781804
);
782805

806+
it.effect("does not deprovision an allocation superseded by a concurrent relink",()=>{
807+
consttunnelCalls: TunnelCall[]=[];
808+
constdnsCalls: DnsCall[]=[];
809+
constallocationCalls: AllocationCall[]=[];
810+
constlayer=providerLayer(
811+
makePersistentTunnelClient(tunnelCalls),
812+
makeDnsClient(dnsCalls),
813+
makeAllocations(allocationCalls),
814+
);
815+
816+
returnEffect.gen(function*(){
817+
constprovider=yield*ManagedEndpointProvider.ManagedEndpointProvider;
818+
constkey={userId: "user_ABC",environmentId: "env_ABC"}asconst;
819+
constrequest={
820+
...key,
821+
origin: {localHttpHost: "127.0.0.1",localHttpPort: 3773},
822+
}asconst;
823+
yield*provider.provision(request);
824+
constunlinkTarget=yield*provider.prepareDeprovision(key);
825+
expect(unlinkTarget).not.toBeNull();
826+
if(unlinkTarget===null){
827+
return;
828+
}
829+
830+
// A relink refreshes the allocation generation after unlink captured its
831+
// target but before unlink begins external teardown.
832+
yield*provider.provision(request);
833+
consttunnelCallCount=tunnelCalls.length;
834+
constdnsCallCount=dnsCalls.length;
835+
constallocationCallCount=allocationCalls.length;
836+
837+
yield*provider.deprovision({ ...key,target: unlinkTarget});
838+
839+
expect(tunnelCalls).toHaveLength(tunnelCallCount);
840+
expect(dnsCalls).toHaveLength(dnsCallCount);
841+
expect(allocationCalls.slice(allocationCallCount).map((call)=>call.operation)).toEqual([
842+
"claimDeprovision",
843+
]);
844+
}).pipe(Effect.provide(layer));
845+
});
846+
783847
it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname",()=>{
784848
consttunnelCalls: TunnelCall[]=[];
785849
constdnsCalls: DnsCall[]=[];
@@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => {
10041068
"recordDns",
10051069
"markReady",
10061070
"get",
1071+
"claimDeprovision",
10071072
"get",
1008-
"remove",
1073+
"claimDeprovision",
1074+
"removeClaimed",
10091075
]);
10101076
}).pipe(Effect.provide(layer));
10111077
});
@@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => {
10461112
});
10471113
yield*provider.deprovision(key);
10481114

1049-
expect(allocationCalls.map((call)=>call.operation)).toContain("remove");
1115+
expect(allocationCalls.map((call)=>call.operation)).toContain("removeClaimed");
10501116
}).pipe(Effect.provide(layer));
10511117
});
10521118

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit baa3abd

Browse files
fix(relay): guard unlink teardown generation
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent b2203fa commit baa3abd

8 files changed

Lines changed: 318 additions & 27 deletions

‎infra/relay/src/environments/EnvironmentConnector.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ function makeAllocations(
198198
recordDns: ()=>Effect.die("unused"),
199199
markReady: ()=>Effect.die("unused"),
200200
claimRelease: ()=>Effect.die("unused"),
201+
claimDeprovision: ()=>Effect.die("unused"),
201202
remove: ()=>Effect.die("unused"),
203+
removeClaimed: ()=>Effect.die("unused"),
202204
};
203205
}
204206

‎infra/relay/src/environments/EnvironmentLinker.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function testLayer(input?: {
136136
revokeForEnvironmentPublicKey: ()=>Effect.succeed(false),
137137
}),
138138
Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider,{
139+
prepareDeprovision: ()=>Effect.succeed(null),
139140
deprovision: input?.deprovision??(()=>Effect.void),
140141
release: ()=>Effect.succeed(true),
141142
provision: ()=>

‎infra/relay/src/environments/ManagedEndpointAllocations.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
1010
ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb,db)));
1111

1212
describe("ManagedEndpointAllocations",()=>{
13+
it.effect("returns a claim generation only when deprovision wins the allocation CAS",()=>{
14+
letclaimedAt: string|undefined;
15+
constfakeDb={
16+
update: (table: unknown)=>{
17+
expect(table).toBe(relayManagedEndpointAllocations);
18+
return{
19+
set: (values: {readonlyupdatedAt: string})=>{
20+
claimedAt=values.updatedAt;
21+
return{
22+
where: ()=>({
23+
returning: ()=>Effect.succeed([{userId: "user-1"}]),
24+
}),
25+
};
26+
},
27+
};
28+
},
29+
}asunknownasRelayDb.RelayDb["Service"];
30+
31+
returnEffect.gen(function*(){
32+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
33+
constgeneration=yield*allocations.claimDeprovision({
34+
userId: "user-1",
35+
environmentId: "environment-1",
36+
updatedAt: "captured-generation",
37+
});
38+
39+
expect(generation).toBe(claimedAt);
40+
expect(generation).not.toBeNull();
41+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
42+
});
43+
44+
it.effect("does not remove an allocation superseded after a deprovision claim",()=>{
45+
constfakeDb={
46+
delete: (table: unknown)=>{
47+
expect(table).toBe(relayManagedEndpointAllocations);
48+
return{
49+
where: ()=>({
50+
returning: ()=>Effect.succeed([]),
51+
}),
52+
};
53+
},
54+
}asunknownasRelayDb.RelayDb["Service"];
55+
56+
returnEffect.gen(function*(){
57+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
58+
expect(
59+
yield*allocations.removeClaimed({
60+
userId: "user-1",
61+
environmentId: "environment-1",
62+
updatedAt: "outdated-claim-generation",
63+
}),
64+
).toBe(false);
65+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
66+
});
67+
1368
it.effect("retains database failures with allocation operation and identity",()=>{
1469
constcause=newError("database unavailable");
1570
constfakeDb={

‎infra/relay/src/environments/ManagedEndpointAllocations.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
5151
"record-dns",
5252
"mark-ready",
5353
"claim-release",
54+
"claim-deprovision",
5455
"remove",
56+
"remove-claimed",
5557
]),
5658
stage: Schema.Literals(["database-request","resolve-reservation"]),
5759
userId: Schema.String,
@@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey
9193
readonlyupdatedAt: string;
9294
}
9395

96+
interfaceClaimManagedEndpointDeprovisionInputextendsManagedEndpointAllocationKey{
97+
readonlyupdatedAt: string;
98+
}
99+
100+
interfaceRemoveClaimedManagedEndpointAllocationInputextendsManagedEndpointAllocationKey{
101+
readonlyupdatedAt: string;
102+
}
103+
94104
exportclassManagedEndpointAllocationsextendsContext.Service<
95105
ManagedEndpointAllocations,
96106
{
@@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service<
119129
readonlyclaimRelease: (
120130
input: ClaimManagedEndpointReleaseInput,
121131
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
132+
/**
133+
* Claims the complete allocation for teardown only if its generation still
134+
* matches the snapshot captured by the unlink operation.
135+
*
136+
* Returns the claim generation used by `removeClaimed`, or null when a
137+
* concurrent provision has already superseded the snapshot.
138+
*/
139+
readonlyclaimDeprovision: (
140+
input: ClaimManagedEndpointDeprovisionInput,
141+
)=>Effect.Effect<string|null,ManagedEndpointAllocationPersistenceError>;
122142
readonlyremove: (
123143
input: ManagedEndpointAllocationKey,
124144
)=>Effect.Effect<void,ManagedEndpointAllocationPersistenceError>;
145+
readonlyremoveClaimed: (
146+
input: RemoveClaimedManagedEndpointAllocationInput,
147+
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
125148
}
126149
>()("t3code-relay/environments/ManagedEndpointAllocations"){}
127150

@@ -321,6 +344,35 @@ export const make = Effect.gen(function* () {
321344
);
322345
returnclaimed;
323346
}),
347+
claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function*(
348+
input: ClaimManagedEndpointDeprovisionInput,
349+
){
350+
constclaimedAt=DateTime.formatIso(yield*DateTime.now);
351+
constclaimed=yield*db
352+
.update(relayManagedEndpointAllocations)
353+
.set({updatedAt: claimedAt})
354+
.where(
355+
and(
356+
whereAllocation(input),
357+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
358+
),
359+
)
360+
.returning({userId: relayManagedEndpointAllocations.userId})
361+
.pipe(
362+
Effect.map((rows)=>rows.length>0),
363+
Effect.mapError(
364+
(cause)=>
365+
newManagedEndpointAllocationPersistenceError({
366+
operation: "claim-deprovision",
367+
stage: "database-request",
368+
userId: input.userId,
369+
environmentId: input.environmentId,
370+
cause,
371+
}),
372+
),
373+
);
374+
returnclaimed ? claimedAt : null;
375+
}),
324376
remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function*(
325377
input: ManagedEndpointAllocationKey,
326378
){
@@ -339,6 +391,32 @@ export const make = Effect.gen(function* () {
339391
),
340392
);
341393
}),
394+
removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function*(
395+
input: RemoveClaimedManagedEndpointAllocationInput,
396+
){
397+
returnyield*db
398+
.delete(relayManagedEndpointAllocations)
399+
.where(
400+
and(
401+
whereAllocation(input),
402+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
403+
),
404+
)
405+
.returning({userId: relayManagedEndpointAllocations.userId})
406+
.pipe(
407+
Effect.map((rows)=>rows.length>0),
408+
Effect.mapError(
409+
(cause)=>
410+
newManagedEndpointAllocationPersistenceError({
411+
operation: "remove-claimed",
412+
stage: "database-request",
413+
userId: input.userId,
414+
environmentId: input.environmentId,
415+
cause,
416+
}),
417+
),
418+
);
419+
}),
342420
});
343421
});
344422

‎infra/relay/src/environments/ManagedEndpointProvider.test.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ interface AllocationCall {
5050
|"recordDns"
5151
|"markReady"
5252
|"claimRelease"
53-
|"remove";
53+
|"claimDeprovision"
54+
|"remove"
55+
|"removeClaimed";
5456
readonlyinput: unknown;
5557
}
5658

@@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) {
226228
mutate(allocationKey(input),(current)=>current);
227229
returntrue;
228230
}),
231+
claimDeprovision: (input)=>
232+
Effect.sync(()=>{
233+
calls.push({operation: "claimDeprovision", input });
234+
constallocation=allocations.get(allocationKey(input));
235+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
236+
returnnull;
237+
}
238+
mutate(allocationKey(input),(current)=>current);
239+
returnallocations.get(allocationKey(input))?.updatedAt??null;
240+
}),
229241
remove: (input)=>
230242
Effect.sync(()=>{
231243
calls.push({operation: "remove", input });
232244
allocations.delete(allocationKey(input));
233245
}),
246+
removeClaimed: (input)=>
247+
Effect.sync(()=>{
248+
calls.push({operation: "removeClaimed", input });
249+
constallocation=allocations.get(allocationKey(input));
250+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
251+
returnfalse;
252+
}
253+
allocations.delete(allocationKey(input));
254+
returntrue;
255+
}),
234256
});
235257
}
236258

@@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => {
774796
"recordDns",
775797
"markReady",
776798
"get",
777-
"remove",
799+
"claimDeprovision",
800+
"removeClaimed",
778801
]);
779802
}).pipe(Effect.provide(layer));
780803
},
781804
);
782805

806+
it.effect("does not deprovision an allocation superseded by a concurrent relink",()=>{
807+
consttunnelCalls: TunnelCall[]=[];
808+
constdnsCalls: DnsCall[]=[];
809+
constallocationCalls: AllocationCall[]=[];
810+
constlayer=providerLayer(
811+
makePersistentTunnelClient(tunnelCalls),
812+
makeDnsClient(dnsCalls),
813+
makeAllocations(allocationCalls),
814+
);
815+
816+
returnEffect.gen(function*(){
817+
constprovider=yield*ManagedEndpointProvider.ManagedEndpointProvider;
818+
constkey={userId: "user_ABC",environmentId: "env_ABC"}asconst;
819+
constrequest={
820+
...key,
821+
origin: {localHttpHost: "127.0.0.1",localHttpPort: 3773},
822+
}asconst;
823+
yield*provider.provision(request);
824+
constunlinkTarget=yield*provider.prepareDeprovision(key);
825+
expect(unlinkTarget).not.toBeNull();
826+
if(unlinkTarget===null){
827+
return;
828+
}
829+
830+
// A relink refreshes the allocation generation after unlink captured its
831+
// target but before unlink begins external teardown.
832+
yield*provider.provision(request);
833+
consttunnelCallCount=tunnelCalls.length;
834+
constdnsCallCount=dnsCalls.length;
835+
constallocationCallCount=allocationCalls.length;
836+
837+
yield*provider.deprovision({ ...key,target: unlinkTarget});
838+
839+
expect(tunnelCalls).toHaveLength(tunnelCallCount);
840+
expect(dnsCalls).toHaveLength(dnsCallCount);
841+
expect(allocationCalls.slice(allocationCallCount).map((call)=>call.operation)).toEqual([
842+
"claimDeprovision",
843+
]);
844+
}).pipe(Effect.provide(layer));
845+
});
846+
783847
it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname",()=>{
784848
consttunnelCalls: TunnelCall[]=[];
785849
constdnsCalls: DnsCall[]=[];
@@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => {
10041068
"recordDns",
10051069
"markReady",
10061070
"get",
1071+
"claimDeprovision",
10071072
"get",
1008-
"remove",
1073+
"claimDeprovision",
1074+
"removeClaimed",
10091075
]);
10101076
}).pipe(Effect.provide(layer));
10111077
});
@@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => {
10461112
});
10471113
yield*provider.deprovision(key);
10481114

1049-
expect(allocationCalls.map((call)=>call.operation)).toContain("remove");
1115+
expect(allocationCalls.map((call)=>call.operation)).toContain("removeClaimed");
10501116
}).pipe(Effect.provide(layer));
10511117
});
10521118

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit baa3abd

Browse files
fix(relay): guard unlink teardown generation
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent b2203fa commit baa3abd

8 files changed

Lines changed: 318 additions & 27 deletions

‎infra/relay/src/environments/EnvironmentConnector.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ function makeAllocations(
198198
recordDns: ()=>Effect.die("unused"),
199199
markReady: ()=>Effect.die("unused"),
200200
claimRelease: ()=>Effect.die("unused"),
201+
claimDeprovision: ()=>Effect.die("unused"),
201202
remove: ()=>Effect.die("unused"),
203+
removeClaimed: ()=>Effect.die("unused"),
202204
};
203205
}
204206

‎infra/relay/src/environments/EnvironmentLinker.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function testLayer(input?: {
136136
revokeForEnvironmentPublicKey: ()=>Effect.succeed(false),
137137
}),
138138
Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider,{
139+
prepareDeprovision: ()=>Effect.succeed(null),
139140
deprovision: input?.deprovision??(()=>Effect.void),
140141
release: ()=>Effect.succeed(true),
141142
provision: ()=>

‎infra/relay/src/environments/ManagedEndpointAllocations.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
1010
ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb,db)));
1111

1212
describe("ManagedEndpointAllocations",()=>{
13+
it.effect("returns a claim generation only when deprovision wins the allocation CAS",()=>{
14+
letclaimedAt: string|undefined;
15+
constfakeDb={
16+
update: (table: unknown)=>{
17+
expect(table).toBe(relayManagedEndpointAllocations);
18+
return{
19+
set: (values: {readonlyupdatedAt: string})=>{
20+
claimedAt=values.updatedAt;
21+
return{
22+
where: ()=>({
23+
returning: ()=>Effect.succeed([{userId: "user-1"}]),
24+
}),
25+
};
26+
},
27+
};
28+
},
29+
}asunknownasRelayDb.RelayDb["Service"];
30+
31+
returnEffect.gen(function*(){
32+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
33+
constgeneration=yield*allocations.claimDeprovision({
34+
userId: "user-1",
35+
environmentId: "environment-1",
36+
updatedAt: "captured-generation",
37+
});
38+
39+
expect(generation).toBe(claimedAt);
40+
expect(generation).not.toBeNull();
41+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
42+
});
43+
44+
it.effect("does not remove an allocation superseded after a deprovision claim",()=>{
45+
constfakeDb={
46+
delete: (table: unknown)=>{
47+
expect(table).toBe(relayManagedEndpointAllocations);
48+
return{
49+
where: ()=>({
50+
returning: ()=>Effect.succeed([]),
51+
}),
52+
};
53+
},
54+
}asunknownasRelayDb.RelayDb["Service"];
55+
56+
returnEffect.gen(function*(){
57+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
58+
expect(
59+
yield*allocations.removeClaimed({
60+
userId: "user-1",
61+
environmentId: "environment-1",
62+
updatedAt: "outdated-claim-generation",
63+
}),
64+
).toBe(false);
65+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
66+
});
67+
1368
it.effect("retains database failures with allocation operation and identity",()=>{
1469
constcause=newError("database unavailable");
1570
constfakeDb={

‎infra/relay/src/environments/ManagedEndpointAllocations.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
5151
"record-dns",
5252
"mark-ready",
5353
"claim-release",
54+
"claim-deprovision",
5455
"remove",
56+
"remove-claimed",
5557
]),
5658
stage: Schema.Literals(["database-request","resolve-reservation"]),
5759
userId: Schema.String,
@@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey
9193
readonlyupdatedAt: string;
9294
}
9395

96+
interfaceClaimManagedEndpointDeprovisionInputextendsManagedEndpointAllocationKey{
97+
readonlyupdatedAt: string;
98+
}
99+
100+
interfaceRemoveClaimedManagedEndpointAllocationInputextendsManagedEndpointAllocationKey{
101+
readonlyupdatedAt: string;
102+
}
103+
94104
exportclassManagedEndpointAllocationsextendsContext.Service<
95105
ManagedEndpointAllocations,
96106
{
@@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service<
119129
readonlyclaimRelease: (
120130
input: ClaimManagedEndpointReleaseInput,
121131
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
132+
/**
133+
* Claims the complete allocation for teardown only if its generation still
134+
* matches the snapshot captured by the unlink operation.
135+
*
136+
* Returns the claim generation used by `removeClaimed`, or null when a
137+
* concurrent provision has already superseded the snapshot.
138+
*/
139+
readonlyclaimDeprovision: (
140+
input: ClaimManagedEndpointDeprovisionInput,
141+
)=>Effect.Effect<string|null,ManagedEndpointAllocationPersistenceError>;
122142
readonlyremove: (
123143
input: ManagedEndpointAllocationKey,
124144
)=>Effect.Effect<void,ManagedEndpointAllocationPersistenceError>;
145+
readonlyremoveClaimed: (
146+
input: RemoveClaimedManagedEndpointAllocationInput,
147+
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
125148
}
126149
>()("t3code-relay/environments/ManagedEndpointAllocations"){}
127150

@@ -321,6 +344,35 @@ export const make = Effect.gen(function* () {
321344
);
322345
returnclaimed;
323346
}),
347+
claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function*(
348+
input: ClaimManagedEndpointDeprovisionInput,
349+
){
350+
constclaimedAt=DateTime.formatIso(yield*DateTime.now);
351+
constclaimed=yield*db
352+
.update(relayManagedEndpointAllocations)
353+
.set({updatedAt: claimedAt})
354+
.where(
355+
and(
356+
whereAllocation(input),
357+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
358+
),
359+
)
360+
.returning({userId: relayManagedEndpointAllocations.userId})
361+
.pipe(
362+
Effect.map((rows)=>rows.length>0),
363+
Effect.mapError(
364+
(cause)=>
365+
newManagedEndpointAllocationPersistenceError({
366+
operation: "claim-deprovision",
367+
stage: "database-request",
368+
userId: input.userId,
369+
environmentId: input.environmentId,
370+
cause,
371+
}),
372+
),
373+
);
374+
returnclaimed ? claimedAt : null;
375+
}),
324376
remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function*(
325377
input: ManagedEndpointAllocationKey,
326378
){
@@ -339,6 +391,32 @@ export const make = Effect.gen(function* () {
339391
),
340392
);
341393
}),
394+
removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function*(
395+
input: RemoveClaimedManagedEndpointAllocationInput,
396+
){
397+
returnyield*db
398+
.delete(relayManagedEndpointAllocations)
399+
.where(
400+
and(
401+
whereAllocation(input),
402+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
403+
),
404+
)
405+
.returning({userId: relayManagedEndpointAllocations.userId})
406+
.pipe(
407+
Effect.map((rows)=>rows.length>0),
408+
Effect.mapError(
409+
(cause)=>
410+
newManagedEndpointAllocationPersistenceError({
411+
operation: "remove-claimed",
412+
stage: "database-request",
413+
userId: input.userId,
414+
environmentId: input.environmentId,
415+
cause,
416+
}),
417+
),
418+
);
419+
}),
342420
});
343421
});
344422

‎infra/relay/src/environments/ManagedEndpointProvider.test.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ interface AllocationCall {
5050
|"recordDns"
5151
|"markReady"
5252
|"claimRelease"
53-
|"remove";
53+
|"claimDeprovision"
54+
|"remove"
55+
|"removeClaimed";
5456
readonlyinput: unknown;
5557
}
5658

@@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) {
226228
mutate(allocationKey(input),(current)=>current);
227229
returntrue;
228230
}),
231+
claimDeprovision: (input)=>
232+
Effect.sync(()=>{
233+
calls.push({operation: "claimDeprovision", input });
234+
constallocation=allocations.get(allocationKey(input));
235+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
236+
returnnull;
237+
}
238+
mutate(allocationKey(input),(current)=>current);
239+
returnallocations.get(allocationKey(input))?.updatedAt??null;
240+
}),
229241
remove: (input)=>
230242
Effect.sync(()=>{
231243
calls.push({operation: "remove", input });
232244
allocations.delete(allocationKey(input));
233245
}),
246+
removeClaimed: (input)=>
247+
Effect.sync(()=>{
248+
calls.push({operation: "removeClaimed", input });
249+
constallocation=allocations.get(allocationKey(input));
250+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
251+
returnfalse;
252+
}
253+
allocations.delete(allocationKey(input));
254+
returntrue;
255+
}),
234256
});
235257
}
236258

@@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => {
774796
"recordDns",
775797
"markReady",
776798
"get",
777-
"remove",
799+
"claimDeprovision",
800+
"removeClaimed",
778801
]);
779802
}).pipe(Effect.provide(layer));
780803
},
781804
);
782805

806+
it.effect("does not deprovision an allocation superseded by a concurrent relink",()=>{
807+
consttunnelCalls: TunnelCall[]=[];
808+
constdnsCalls: DnsCall[]=[];
809+
constallocationCalls: AllocationCall[]=[];
810+
constlayer=providerLayer(
811+
makePersistentTunnelClient(tunnelCalls),
812+
makeDnsClient(dnsCalls),
813+
makeAllocations(allocationCalls),
814+
);
815+
816+
returnEffect.gen(function*(){
817+
constprovider=yield*ManagedEndpointProvider.ManagedEndpointProvider;
818+
constkey={userId: "user_ABC",environmentId: "env_ABC"}asconst;
819+
constrequest={
820+
...key,
821+
origin: {localHttpHost: "127.0.0.1",localHttpPort: 3773},
822+
}asconst;
823+
yield*provider.provision(request);
824+
constunlinkTarget=yield*provider.prepareDeprovision(key);
825+
expect(unlinkTarget).not.toBeNull();
826+
if(unlinkTarget===null){
827+
return;
828+
}
829+
830+
// A relink refreshes the allocation generation after unlink captured its
831+
// target but before unlink begins external teardown.
832+
yield*provider.provision(request);
833+
consttunnelCallCount=tunnelCalls.length;
834+
constdnsCallCount=dnsCalls.length;
835+
constallocationCallCount=allocationCalls.length;
836+
837+
yield*provider.deprovision({ ...key,target: unlinkTarget});
838+
839+
expect(tunnelCalls).toHaveLength(tunnelCallCount);
840+
expect(dnsCalls).toHaveLength(dnsCallCount);
841+
expect(allocationCalls.slice(allocationCallCount).map((call)=>call.operation)).toEqual([
842+
"claimDeprovision",
843+
]);
844+
}).pipe(Effect.provide(layer));
845+
});
846+
783847
it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname",()=>{
784848
consttunnelCalls: TunnelCall[]=[];
785849
constdnsCalls: DnsCall[]=[];
@@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => {
10041068
"recordDns",
10051069
"markReady",
10061070
"get",
1071+
"claimDeprovision",
10071072
"get",
1008-
"remove",
1073+
"claimDeprovision",
1074+
"removeClaimed",
10091075
]);
10101076
}).pipe(Effect.provide(layer));
10111077
});
@@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => {
10461112
});
10471113
yield*provider.deprovision(key);
10481114

1049-
expect(allocationCalls.map((call)=>call.operation)).toContain("remove");
1115+
expect(allocationCalls.map((call)=>call.operation)).toContain("removeClaimed");
10501116
}).pipe(Effect.provide(layer));
10511117
});
10521118

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit baa3abd

Browse files
fix(relay): guard unlink teardown generation
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent b2203fa commit baa3abd

8 files changed

Lines changed: 318 additions & 27 deletions

‎infra/relay/src/environments/EnvironmentConnector.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ function makeAllocations(
198198
recordDns: ()=>Effect.die("unused"),
199199
markReady: ()=>Effect.die("unused"),
200200
claimRelease: ()=>Effect.die("unused"),
201+
claimDeprovision: ()=>Effect.die("unused"),
201202
remove: ()=>Effect.die("unused"),
203+
removeClaimed: ()=>Effect.die("unused"),
202204
};
203205
}
204206

‎infra/relay/src/environments/EnvironmentLinker.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function testLayer(input?: {
136136
revokeForEnvironmentPublicKey: ()=>Effect.succeed(false),
137137
}),
138138
Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider,{
139+
prepareDeprovision: ()=>Effect.succeed(null),
139140
deprovision: input?.deprovision??(()=>Effect.void),
140141
release: ()=>Effect.succeed(true),
141142
provision: ()=>

‎infra/relay/src/environments/ManagedEndpointAllocations.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
1010
ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb,db)));
1111

1212
describe("ManagedEndpointAllocations",()=>{
13+
it.effect("returns a claim generation only when deprovision wins the allocation CAS",()=>{
14+
letclaimedAt: string|undefined;
15+
constfakeDb={
16+
update: (table: unknown)=>{
17+
expect(table).toBe(relayManagedEndpointAllocations);
18+
return{
19+
set: (values: {readonlyupdatedAt: string})=>{
20+
claimedAt=values.updatedAt;
21+
return{
22+
where: ()=>({
23+
returning: ()=>Effect.succeed([{userId: "user-1"}]),
24+
}),
25+
};
26+
},
27+
};
28+
},
29+
}asunknownasRelayDb.RelayDb["Service"];
30+
31+
returnEffect.gen(function*(){
32+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
33+
constgeneration=yield*allocations.claimDeprovision({
34+
userId: "user-1",
35+
environmentId: "environment-1",
36+
updatedAt: "captured-generation",
37+
});
38+
39+
expect(generation).toBe(claimedAt);
40+
expect(generation).not.toBeNull();
41+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
42+
});
43+
44+
it.effect("does not remove an allocation superseded after a deprovision claim",()=>{
45+
constfakeDb={
46+
delete: (table: unknown)=>{
47+
expect(table).toBe(relayManagedEndpointAllocations);
48+
return{
49+
where: ()=>({
50+
returning: ()=>Effect.succeed([]),
51+
}),
52+
};
53+
},
54+
}asunknownasRelayDb.RelayDb["Service"];
55+
56+
returnEffect.gen(function*(){
57+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
58+
expect(
59+
yield*allocations.removeClaimed({
60+
userId: "user-1",
61+
environmentId: "environment-1",
62+
updatedAt: "outdated-claim-generation",
63+
}),
64+
).toBe(false);
65+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
66+
});
67+
1368
it.effect("retains database failures with allocation operation and identity",()=>{
1469
constcause=newError("database unavailable");
1570
constfakeDb={

‎infra/relay/src/environments/ManagedEndpointAllocations.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
5151
"record-dns",
5252
"mark-ready",
5353
"claim-release",
54+
"claim-deprovision",
5455
"remove",
56+
"remove-claimed",
5557
]),
5658
stage: Schema.Literals(["database-request","resolve-reservation"]),
5759
userId: Schema.String,
@@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey
9193
readonlyupdatedAt: string;
9294
}
9395

96+
interfaceClaimManagedEndpointDeprovisionInputextendsManagedEndpointAllocationKey{
97+
readonlyupdatedAt: string;
98+
}
99+
100+
interfaceRemoveClaimedManagedEndpointAllocationInputextendsManagedEndpointAllocationKey{
101+
readonlyupdatedAt: string;
102+
}
103+
94104
exportclassManagedEndpointAllocationsextendsContext.Service<
95105
ManagedEndpointAllocations,
96106
{
@@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service<
119129
readonlyclaimRelease: (
120130
input: ClaimManagedEndpointReleaseInput,
121131
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
132+
/**
133+
* Claims the complete allocation for teardown only if its generation still
134+
* matches the snapshot captured by the unlink operation.
135+
*
136+
* Returns the claim generation used by `removeClaimed`, or null when a
137+
* concurrent provision has already superseded the snapshot.
138+
*/
139+
readonlyclaimDeprovision: (
140+
input: ClaimManagedEndpointDeprovisionInput,
141+
)=>Effect.Effect<string|null,ManagedEndpointAllocationPersistenceError>;
122142
readonlyremove: (
123143
input: ManagedEndpointAllocationKey,
124144
)=>Effect.Effect<void,ManagedEndpointAllocationPersistenceError>;
145+
readonlyremoveClaimed: (
146+
input: RemoveClaimedManagedEndpointAllocationInput,
147+
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
125148
}
126149
>()("t3code-relay/environments/ManagedEndpointAllocations"){}
127150

@@ -321,6 +344,35 @@ export const make = Effect.gen(function* () {
321344
);
322345
returnclaimed;
323346
}),
347+
claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function*(
348+
input: ClaimManagedEndpointDeprovisionInput,
349+
){
350+
constclaimedAt=DateTime.formatIso(yield*DateTime.now);
351+
constclaimed=yield*db
352+
.update(relayManagedEndpointAllocations)
353+
.set({updatedAt: claimedAt})
354+
.where(
355+
and(
356+
whereAllocation(input),
357+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
358+
),
359+
)
360+
.returning({userId: relayManagedEndpointAllocations.userId})
361+
.pipe(
362+
Effect.map((rows)=>rows.length>0),
363+
Effect.mapError(
364+
(cause)=>
365+
newManagedEndpointAllocationPersistenceError({
366+
operation: "claim-deprovision",
367+
stage: "database-request",
368+
userId: input.userId,
369+
environmentId: input.environmentId,
370+
cause,
371+
}),
372+
),
373+
);
374+
returnclaimed ? claimedAt : null;
375+
}),
324376
remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function*(
325377
input: ManagedEndpointAllocationKey,
326378
){
@@ -339,6 +391,32 @@ export const make = Effect.gen(function* () {
339391
),
340392
);
341393
}),
394+
removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function*(
395+
input: RemoveClaimedManagedEndpointAllocationInput,
396+
){
397+
returnyield*db
398+
.delete(relayManagedEndpointAllocations)
399+
.where(
400+
and(
401+
whereAllocation(input),
402+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
403+
),
404+
)
405+
.returning({userId: relayManagedEndpointAllocations.userId})
406+
.pipe(
407+
Effect.map((rows)=>rows.length>0),
408+
Effect.mapError(
409+
(cause)=>
410+
newManagedEndpointAllocationPersistenceError({
411+
operation: "remove-claimed",
412+
stage: "database-request",
413+
userId: input.userId,
414+
environmentId: input.environmentId,
415+
cause,
416+
}),
417+
),
418+
);
419+
}),
342420
});
343421
});
344422

‎infra/relay/src/environments/ManagedEndpointProvider.test.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ interface AllocationCall {
5050
|"recordDns"
5151
|"markReady"
5252
|"claimRelease"
53-
|"remove";
53+
|"claimDeprovision"
54+
|"remove"
55+
|"removeClaimed";
5456
readonlyinput: unknown;
5557
}
5658

@@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) {
226228
mutate(allocationKey(input),(current)=>current);
227229
returntrue;
228230
}),
231+
claimDeprovision: (input)=>
232+
Effect.sync(()=>{
233+
calls.push({operation: "claimDeprovision", input });
234+
constallocation=allocations.get(allocationKey(input));
235+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
236+
returnnull;
237+
}
238+
mutate(allocationKey(input),(current)=>current);
239+
returnallocations.get(allocationKey(input))?.updatedAt??null;
240+
}),
229241
remove: (input)=>
230242
Effect.sync(()=>{
231243
calls.push({operation: "remove", input });
232244
allocations.delete(allocationKey(input));
233245
}),
246+
removeClaimed: (input)=>
247+
Effect.sync(()=>{
248+
calls.push({operation: "removeClaimed", input });
249+
constallocation=allocations.get(allocationKey(input));
250+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
251+
returnfalse;
252+
}
253+
allocations.delete(allocationKey(input));
254+
returntrue;
255+
}),
234256
});
235257
}
236258

@@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => {
774796
"recordDns",
775797
"markReady",
776798
"get",
777-
"remove",
799+
"claimDeprovision",
800+
"removeClaimed",
778801
]);
779802
}).pipe(Effect.provide(layer));
780803
},
781804
);
782805

806+
it.effect("does not deprovision an allocation superseded by a concurrent relink",()=>{
807+
consttunnelCalls: TunnelCall[]=[];
808+
constdnsCalls: DnsCall[]=[];
809+
constallocationCalls: AllocationCall[]=[];
810+
constlayer=providerLayer(
811+
makePersistentTunnelClient(tunnelCalls),
812+
makeDnsClient(dnsCalls),
813+
makeAllocations(allocationCalls),
814+
);
815+
816+
returnEffect.gen(function*(){
817+
constprovider=yield*ManagedEndpointProvider.ManagedEndpointProvider;
818+
constkey={userId: "user_ABC",environmentId: "env_ABC"}asconst;
819+
constrequest={
820+
...key,
821+
origin: {localHttpHost: "127.0.0.1",localHttpPort: 3773},
822+
}asconst;
823+
yield*provider.provision(request);
824+
constunlinkTarget=yield*provider.prepareDeprovision(key);
825+
expect(unlinkTarget).not.toBeNull();
826+
if(unlinkTarget===null){
827+
return;
828+
}
829+
830+
// A relink refreshes the allocation generation after unlink captured its
831+
// target but before unlink begins external teardown.
832+
yield*provider.provision(request);
833+
consttunnelCallCount=tunnelCalls.length;
834+
constdnsCallCount=dnsCalls.length;
835+
constallocationCallCount=allocationCalls.length;
836+
837+
yield*provider.deprovision({ ...key,target: unlinkTarget});
838+
839+
expect(tunnelCalls).toHaveLength(tunnelCallCount);
840+
expect(dnsCalls).toHaveLength(dnsCallCount);
841+
expect(allocationCalls.slice(allocationCallCount).map((call)=>call.operation)).toEqual([
842+
"claimDeprovision",
843+
]);
844+
}).pipe(Effect.provide(layer));
845+
});
846+
783847
it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname",()=>{
784848
consttunnelCalls: TunnelCall[]=[];
785849
constdnsCalls: DnsCall[]=[];
@@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => {
10041068
"recordDns",
10051069
"markReady",
10061070
"get",
1071+
"claimDeprovision",
10071072
"get",
1008-
"remove",
1073+
"claimDeprovision",
1074+
"removeClaimed",
10091075
]);
10101076
}).pipe(Effect.provide(layer));
10111077
});
@@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => {
10461112
});
10471113
yield*provider.deprovision(key);
10481114

1049-
expect(allocationCalls.map((call)=>call.operation)).toContain("remove");
1115+
expect(allocationCalls.map((call)=>call.operation)).toContain("removeClaimed");
10501116
}).pipe(Effect.provide(layer));
10511117
});
10521118

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit baa3abd

Browse files
fix(relay): guard unlink teardown generation
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent b2203fa commit baa3abd

8 files changed

Lines changed: 318 additions & 27 deletions

‎infra/relay/src/environments/EnvironmentConnector.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ function makeAllocations(
198198
recordDns: ()=>Effect.die("unused"),
199199
markReady: ()=>Effect.die("unused"),
200200
claimRelease: ()=>Effect.die("unused"),
201+
claimDeprovision: ()=>Effect.die("unused"),
201202
remove: ()=>Effect.die("unused"),
203+
removeClaimed: ()=>Effect.die("unused"),
202204
};
203205
}
204206

‎infra/relay/src/environments/EnvironmentLinker.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function testLayer(input?: {
136136
revokeForEnvironmentPublicKey: ()=>Effect.succeed(false),
137137
}),
138138
Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider,{
139+
prepareDeprovision: ()=>Effect.succeed(null),
139140
deprovision: input?.deprovision??(()=>Effect.void),
140141
release: ()=>Effect.succeed(true),
141142
provision: ()=>

‎infra/relay/src/environments/ManagedEndpointAllocations.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
1010
ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb,db)));
1111

1212
describe("ManagedEndpointAllocations",()=>{
13+
it.effect("returns a claim generation only when deprovision wins the allocation CAS",()=>{
14+
letclaimedAt: string|undefined;
15+
constfakeDb={
16+
update: (table: unknown)=>{
17+
expect(table).toBe(relayManagedEndpointAllocations);
18+
return{
19+
set: (values: {readonlyupdatedAt: string})=>{
20+
claimedAt=values.updatedAt;
21+
return{
22+
where: ()=>({
23+
returning: ()=>Effect.succeed([{userId: "user-1"}]),
24+
}),
25+
};
26+
},
27+
};
28+
},
29+
}asunknownasRelayDb.RelayDb["Service"];
30+
31+
returnEffect.gen(function*(){
32+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
33+
constgeneration=yield*allocations.claimDeprovision({
34+
userId: "user-1",
35+
environmentId: "environment-1",
36+
updatedAt: "captured-generation",
37+
});
38+
39+
expect(generation).toBe(claimedAt);
40+
expect(generation).not.toBeNull();
41+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
42+
});
43+
44+
it.effect("does not remove an allocation superseded after a deprovision claim",()=>{
45+
constfakeDb={
46+
delete: (table: unknown)=>{
47+
expect(table).toBe(relayManagedEndpointAllocations);
48+
return{
49+
where: ()=>({
50+
returning: ()=>Effect.succeed([]),
51+
}),
52+
};
53+
},
54+
}asunknownasRelayDb.RelayDb["Service"];
55+
56+
returnEffect.gen(function*(){
57+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
58+
expect(
59+
yield*allocations.removeClaimed({
60+
userId: "user-1",
61+
environmentId: "environment-1",
62+
updatedAt: "outdated-claim-generation",
63+
}),
64+
).toBe(false);
65+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
66+
});
67+
1368
it.effect("retains database failures with allocation operation and identity",()=>{
1469
constcause=newError("database unavailable");
1570
constfakeDb={

‎infra/relay/src/environments/ManagedEndpointAllocations.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
5151
"record-dns",
5252
"mark-ready",
5353
"claim-release",
54+
"claim-deprovision",
5455
"remove",
56+
"remove-claimed",
5557
]),
5658
stage: Schema.Literals(["database-request","resolve-reservation"]),
5759
userId: Schema.String,
@@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey
9193
readonlyupdatedAt: string;
9294
}
9395

96+
interfaceClaimManagedEndpointDeprovisionInputextendsManagedEndpointAllocationKey{
97+
readonlyupdatedAt: string;
98+
}
99+
100+
interfaceRemoveClaimedManagedEndpointAllocationInputextendsManagedEndpointAllocationKey{
101+
readonlyupdatedAt: string;
102+
}
103+
94104
exportclassManagedEndpointAllocationsextendsContext.Service<
95105
ManagedEndpointAllocations,
96106
{
@@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service<
119129
readonlyclaimRelease: (
120130
input: ClaimManagedEndpointReleaseInput,
121131
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
132+
/**
133+
* Claims the complete allocation for teardown only if its generation still
134+
* matches the snapshot captured by the unlink operation.
135+
*
136+
* Returns the claim generation used by `removeClaimed`, or null when a
137+
* concurrent provision has already superseded the snapshot.
138+
*/
139+
readonlyclaimDeprovision: (
140+
input: ClaimManagedEndpointDeprovisionInput,
141+
)=>Effect.Effect<string|null,ManagedEndpointAllocationPersistenceError>;
122142
readonlyremove: (
123143
input: ManagedEndpointAllocationKey,
124144
)=>Effect.Effect<void,ManagedEndpointAllocationPersistenceError>;
145+
readonlyremoveClaimed: (
146+
input: RemoveClaimedManagedEndpointAllocationInput,
147+
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
125148
}
126149
>()("t3code-relay/environments/ManagedEndpointAllocations"){}
127150

@@ -321,6 +344,35 @@ export const make = Effect.gen(function* () {
321344
);
322345
returnclaimed;
323346
}),
347+
claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function*(
348+
input: ClaimManagedEndpointDeprovisionInput,
349+
){
350+
constclaimedAt=DateTime.formatIso(yield*DateTime.now);
351+
constclaimed=yield*db
352+
.update(relayManagedEndpointAllocations)
353+
.set({updatedAt: claimedAt})
354+
.where(
355+
and(
356+
whereAllocation(input),
357+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
358+
),
359+
)
360+
.returning({userId: relayManagedEndpointAllocations.userId})
361+
.pipe(
362+
Effect.map((rows)=>rows.length>0),
363+
Effect.mapError(
364+
(cause)=>
365+
newManagedEndpointAllocationPersistenceError({
366+
operation: "claim-deprovision",
367+
stage: "database-request",
368+
userId: input.userId,
369+
environmentId: input.environmentId,
370+
cause,
371+
}),
372+
),
373+
);
374+
returnclaimed ? claimedAt : null;
375+
}),
324376
remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function*(
325377
input: ManagedEndpointAllocationKey,
326378
){
@@ -339,6 +391,32 @@ export const make = Effect.gen(function* () {
339391
),
340392
);
341393
}),
394+
removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function*(
395+
input: RemoveClaimedManagedEndpointAllocationInput,
396+
){
397+
returnyield*db
398+
.delete(relayManagedEndpointAllocations)
399+
.where(
400+
and(
401+
whereAllocation(input),
402+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
403+
),
404+
)
405+
.returning({userId: relayManagedEndpointAllocations.userId})
406+
.pipe(
407+
Effect.map((rows)=>rows.length>0),
408+
Effect.mapError(
409+
(cause)=>
410+
newManagedEndpointAllocationPersistenceError({
411+
operation: "remove-claimed",
412+
stage: "database-request",
413+
userId: input.userId,
414+
environmentId: input.environmentId,
415+
cause,
416+
}),
417+
),
418+
);
419+
}),
342420
});
343421
});
344422

‎infra/relay/src/environments/ManagedEndpointProvider.test.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ interface AllocationCall {
5050
|"recordDns"
5151
|"markReady"
5252
|"claimRelease"
53-
|"remove";
53+
|"claimDeprovision"
54+
|"remove"
55+
|"removeClaimed";
5456
readonlyinput: unknown;
5557
}
5658

@@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) {
226228
mutate(allocationKey(input),(current)=>current);
227229
returntrue;
228230
}),
231+
claimDeprovision: (input)=>
232+
Effect.sync(()=>{
233+
calls.push({operation: "claimDeprovision", input });
234+
constallocation=allocations.get(allocationKey(input));
235+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
236+
returnnull;
237+
}
238+
mutate(allocationKey(input),(current)=>current);
239+
returnallocations.get(allocationKey(input))?.updatedAt??null;
240+
}),
229241
remove: (input)=>
230242
Effect.sync(()=>{
231243
calls.push({operation: "remove", input });
232244
allocations.delete(allocationKey(input));
233245
}),
246+
removeClaimed: (input)=>
247+
Effect.sync(()=>{
248+
calls.push({operation: "removeClaimed", input });
249+
constallocation=allocations.get(allocationKey(input));
250+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
251+
returnfalse;
252+
}
253+
allocations.delete(allocationKey(input));
254+
returntrue;
255+
}),
234256
});
235257
}
236258

@@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => {
774796
"recordDns",
775797
"markReady",
776798
"get",
777-
"remove",
799+
"claimDeprovision",
800+
"removeClaimed",
778801
]);
779802
}).pipe(Effect.provide(layer));
780803
},
781804
);
782805

806+
it.effect("does not deprovision an allocation superseded by a concurrent relink",()=>{
807+
consttunnelCalls: TunnelCall[]=[];
808+
constdnsCalls: DnsCall[]=[];
809+
constallocationCalls: AllocationCall[]=[];
810+
constlayer=providerLayer(
811+
makePersistentTunnelClient(tunnelCalls),
812+
makeDnsClient(dnsCalls),
813+
makeAllocations(allocationCalls),
814+
);
815+
816+
returnEffect.gen(function*(){
817+
constprovider=yield*ManagedEndpointProvider.ManagedEndpointProvider;
818+
constkey={userId: "user_ABC",environmentId: "env_ABC"}asconst;
819+
constrequest={
820+
...key,
821+
origin: {localHttpHost: "127.0.0.1",localHttpPort: 3773},
822+
}asconst;
823+
yield*provider.provision(request);
824+
constunlinkTarget=yield*provider.prepareDeprovision(key);
825+
expect(unlinkTarget).not.toBeNull();
826+
if(unlinkTarget===null){
827+
return;
828+
}
829+
830+
// A relink refreshes the allocation generation after unlink captured its
831+
// target but before unlink begins external teardown.
832+
yield*provider.provision(request);
833+
consttunnelCallCount=tunnelCalls.length;
834+
constdnsCallCount=dnsCalls.length;
835+
constallocationCallCount=allocationCalls.length;
836+
837+
yield*provider.deprovision({ ...key,target: unlinkTarget});
838+
839+
expect(tunnelCalls).toHaveLength(tunnelCallCount);
840+
expect(dnsCalls).toHaveLength(dnsCallCount);
841+
expect(allocationCalls.slice(allocationCallCount).map((call)=>call.operation)).toEqual([
842+
"claimDeprovision",
843+
]);
844+
}).pipe(Effect.provide(layer));
845+
});
846+
783847
it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname",()=>{
784848
consttunnelCalls: TunnelCall[]=[];
785849
constdnsCalls: DnsCall[]=[];
@@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => {
10041068
"recordDns",
10051069
"markReady",
10061070
"get",
1071+
"claimDeprovision",
10071072
"get",
1008-
"remove",
1073+
"claimDeprovision",
1074+
"removeClaimed",
10091075
]);
10101076
}).pipe(Effect.provide(layer));
10111077
});
@@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => {
10461112
});
10471113
yield*provider.deprovision(key);
10481114

1049-
expect(allocationCalls.map((call)=>call.operation)).toContain("remove");
1115+
expect(allocationCalls.map((call)=>call.operation)).toContain("removeClaimed");
10501116
}).pipe(Effect.provide(layer));
10511117
});
10521118

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit baa3abd

Browse files
fix(relay): guard unlink teardown generation
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent b2203fa commit baa3abd

8 files changed

Lines changed: 318 additions & 27 deletions

‎infra/relay/src/environments/EnvironmentConnector.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ function makeAllocations(
198198
recordDns: ()=>Effect.die("unused"),
199199
markReady: ()=>Effect.die("unused"),
200200
claimRelease: ()=>Effect.die("unused"),
201+
claimDeprovision: ()=>Effect.die("unused"),
201202
remove: ()=>Effect.die("unused"),
203+
removeClaimed: ()=>Effect.die("unused"),
202204
};
203205
}
204206

‎infra/relay/src/environments/EnvironmentLinker.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function testLayer(input?: {
136136
revokeForEnvironmentPublicKey: ()=>Effect.succeed(false),
137137
}),
138138
Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider,{
139+
prepareDeprovision: ()=>Effect.succeed(null),
139140
deprovision: input?.deprovision??(()=>Effect.void),
140141
release: ()=>Effect.succeed(true),
141142
provision: ()=>

‎infra/relay/src/environments/ManagedEndpointAllocations.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
1010
ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb,db)));
1111

1212
describe("ManagedEndpointAllocations",()=>{
13+
it.effect("returns a claim generation only when deprovision wins the allocation CAS",()=>{
14+
letclaimedAt: string|undefined;
15+
constfakeDb={
16+
update: (table: unknown)=>{
17+
expect(table).toBe(relayManagedEndpointAllocations);
18+
return{
19+
set: (values: {readonlyupdatedAt: string})=>{
20+
claimedAt=values.updatedAt;
21+
return{
22+
where: ()=>({
23+
returning: ()=>Effect.succeed([{userId: "user-1"}]),
24+
}),
25+
};
26+
},
27+
};
28+
},
29+
}asunknownasRelayDb.RelayDb["Service"];
30+
31+
returnEffect.gen(function*(){
32+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
33+
constgeneration=yield*allocations.claimDeprovision({
34+
userId: "user-1",
35+
environmentId: "environment-1",
36+
updatedAt: "captured-generation",
37+
});
38+
39+
expect(generation).toBe(claimedAt);
40+
expect(generation).not.toBeNull();
41+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
42+
});
43+
44+
it.effect("does not remove an allocation superseded after a deprovision claim",()=>{
45+
constfakeDb={
46+
delete: (table: unknown)=>{
47+
expect(table).toBe(relayManagedEndpointAllocations);
48+
return{
49+
where: ()=>({
50+
returning: ()=>Effect.succeed([]),
51+
}),
52+
};
53+
},
54+
}asunknownasRelayDb.RelayDb["Service"];
55+
56+
returnEffect.gen(function*(){
57+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
58+
expect(
59+
yield*allocations.removeClaimed({
60+
userId: "user-1",
61+
environmentId: "environment-1",
62+
updatedAt: "outdated-claim-generation",
63+
}),
64+
).toBe(false);
65+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
66+
});
67+
1368
it.effect("retains database failures with allocation operation and identity",()=>{
1469
constcause=newError("database unavailable");
1570
constfakeDb={

‎infra/relay/src/environments/ManagedEndpointAllocations.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
5151
"record-dns",
5252
"mark-ready",
5353
"claim-release",
54+
"claim-deprovision",
5455
"remove",
56+
"remove-claimed",
5557
]),
5658
stage: Schema.Literals(["database-request","resolve-reservation"]),
5759
userId: Schema.String,
@@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey
9193
readonlyupdatedAt: string;
9294
}
9395

96+
interfaceClaimManagedEndpointDeprovisionInputextendsManagedEndpointAllocationKey{
97+
readonlyupdatedAt: string;
98+
}
99+
100+
interfaceRemoveClaimedManagedEndpointAllocationInputextendsManagedEndpointAllocationKey{
101+
readonlyupdatedAt: string;
102+
}
103+
94104
exportclassManagedEndpointAllocationsextendsContext.Service<
95105
ManagedEndpointAllocations,
96106
{
@@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service<
119129
readonlyclaimRelease: (
120130
input: ClaimManagedEndpointReleaseInput,
121131
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
132+
/**
133+
* Claims the complete allocation for teardown only if its generation still
134+
* matches the snapshot captured by the unlink operation.
135+
*
136+
* Returns the claim generation used by `removeClaimed`, or null when a
137+
* concurrent provision has already superseded the snapshot.
138+
*/
139+
readonlyclaimDeprovision: (
140+
input: ClaimManagedEndpointDeprovisionInput,
141+
)=>Effect.Effect<string|null,ManagedEndpointAllocationPersistenceError>;
122142
readonlyremove: (
123143
input: ManagedEndpointAllocationKey,
124144
)=>Effect.Effect<void,ManagedEndpointAllocationPersistenceError>;
145+
readonlyremoveClaimed: (
146+
input: RemoveClaimedManagedEndpointAllocationInput,
147+
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
125148
}
126149
>()("t3code-relay/environments/ManagedEndpointAllocations"){}
127150

@@ -321,6 +344,35 @@ export const make = Effect.gen(function* () {
321344
);
322345
returnclaimed;
323346
}),
347+
claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function*(
348+
input: ClaimManagedEndpointDeprovisionInput,
349+
){
350+
constclaimedAt=DateTime.formatIso(yield*DateTime.now);
351+
constclaimed=yield*db
352+
.update(relayManagedEndpointAllocations)
353+
.set({updatedAt: claimedAt})
354+
.where(
355+
and(
356+
whereAllocation(input),
357+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
358+
),
359+
)
360+
.returning({userId: relayManagedEndpointAllocations.userId})
361+
.pipe(
362+
Effect.map((rows)=>rows.length>0),
363+
Effect.mapError(
364+
(cause)=>
365+
newManagedEndpointAllocationPersistenceError({
366+
operation: "claim-deprovision",
367+
stage: "database-request",
368+
userId: input.userId,
369+
environmentId: input.environmentId,
370+
cause,
371+
}),
372+
),
373+
);
374+
returnclaimed ? claimedAt : null;
375+
}),
324376
remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function*(
325377
input: ManagedEndpointAllocationKey,
326378
){
@@ -339,6 +391,32 @@ export const make = Effect.gen(function* () {
339391
),
340392
);
341393
}),
394+
removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function*(
395+
input: RemoveClaimedManagedEndpointAllocationInput,
396+
){
397+
returnyield*db
398+
.delete(relayManagedEndpointAllocations)
399+
.where(
400+
and(
401+
whereAllocation(input),
402+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
403+
),
404+
)
405+
.returning({userId: relayManagedEndpointAllocations.userId})
406+
.pipe(
407+
Effect.map((rows)=>rows.length>0),
408+
Effect.mapError(
409+
(cause)=>
410+
newManagedEndpointAllocationPersistenceError({
411+
operation: "remove-claimed",
412+
stage: "database-request",
413+
userId: input.userId,
414+
environmentId: input.environmentId,
415+
cause,
416+
}),
417+
),
418+
);
419+
}),
342420
});
343421
});
344422

‎infra/relay/src/environments/ManagedEndpointProvider.test.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ interface AllocationCall {
5050
|"recordDns"
5151
|"markReady"
5252
|"claimRelease"
53-
|"remove";
53+
|"claimDeprovision"
54+
|"remove"
55+
|"removeClaimed";
5456
readonlyinput: unknown;
5557
}
5658

@@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) {
226228
mutate(allocationKey(input),(current)=>current);
227229
returntrue;
228230
}),
231+
claimDeprovision: (input)=>
232+
Effect.sync(()=>{
233+
calls.push({operation: "claimDeprovision", input });
234+
constallocation=allocations.get(allocationKey(input));
235+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
236+
returnnull;
237+
}
238+
mutate(allocationKey(input),(current)=>current);
239+
returnallocations.get(allocationKey(input))?.updatedAt??null;
240+
}),
229241
remove: (input)=>
230242
Effect.sync(()=>{
231243
calls.push({operation: "remove", input });
232244
allocations.delete(allocationKey(input));
233245
}),
246+
removeClaimed: (input)=>
247+
Effect.sync(()=>{
248+
calls.push({operation: "removeClaimed", input });
249+
constallocation=allocations.get(allocationKey(input));
250+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
251+
returnfalse;
252+
}
253+
allocations.delete(allocationKey(input));
254+
returntrue;
255+
}),
234256
});
235257
}
236258

@@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => {
774796
"recordDns",
775797
"markReady",
776798
"get",
777-
"remove",
799+
"claimDeprovision",
800+
"removeClaimed",
778801
]);
779802
}).pipe(Effect.provide(layer));
780803
},
781804
);
782805

806+
it.effect("does not deprovision an allocation superseded by a concurrent relink",()=>{
807+
consttunnelCalls: TunnelCall[]=[];
808+
constdnsCalls: DnsCall[]=[];
809+
constallocationCalls: AllocationCall[]=[];
810+
constlayer=providerLayer(
811+
makePersistentTunnelClient(tunnelCalls),
812+
makeDnsClient(dnsCalls),
813+
makeAllocations(allocationCalls),
814+
);
815+
816+
returnEffect.gen(function*(){
817+
constprovider=yield*ManagedEndpointProvider.ManagedEndpointProvider;
818+
constkey={userId: "user_ABC",environmentId: "env_ABC"}asconst;
819+
constrequest={
820+
...key,
821+
origin: {localHttpHost: "127.0.0.1",localHttpPort: 3773},
822+
}asconst;
823+
yield*provider.provision(request);
824+
constunlinkTarget=yield*provider.prepareDeprovision(key);
825+
expect(unlinkTarget).not.toBeNull();
826+
if(unlinkTarget===null){
827+
return;
828+
}
829+
830+
// A relink refreshes the allocation generation after unlink captured its
831+
// target but before unlink begins external teardown.
832+
yield*provider.provision(request);
833+
consttunnelCallCount=tunnelCalls.length;
834+
constdnsCallCount=dnsCalls.length;
835+
constallocationCallCount=allocationCalls.length;
836+
837+
yield*provider.deprovision({ ...key,target: unlinkTarget});
838+
839+
expect(tunnelCalls).toHaveLength(tunnelCallCount);
840+
expect(dnsCalls).toHaveLength(dnsCallCount);
841+
expect(allocationCalls.slice(allocationCallCount).map((call)=>call.operation)).toEqual([
842+
"claimDeprovision",
843+
]);
844+
}).pipe(Effect.provide(layer));
845+
});
846+
783847
it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname",()=>{
784848
consttunnelCalls: TunnelCall[]=[];
785849
constdnsCalls: DnsCall[]=[];
@@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => {
10041068
"recordDns",
10051069
"markReady",
10061070
"get",
1071+
"claimDeprovision",
10071072
"get",
1008-
"remove",
1073+
"claimDeprovision",
1074+
"removeClaimed",
10091075
]);
10101076
}).pipe(Effect.provide(layer));
10111077
});
@@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => {
10461112
});
10471113
yield*provider.deprovision(key);
10481114

1049-
expect(allocationCalls.map((call)=>call.operation)).toContain("remove");
1115+
expect(allocationCalls.map((call)=>call.operation)).toContain("removeClaimed");
10501116
}).pipe(Effect.provide(layer));
10511117
});
10521118

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit baa3abd

Browse files
fix(relay): guard unlink teardown generation
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent b2203fa commit baa3abd

8 files changed

Lines changed: 318 additions & 27 deletions

‎infra/relay/src/environments/EnvironmentConnector.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ function makeAllocations(
198198
recordDns: ()=>Effect.die("unused"),
199199
markReady: ()=>Effect.die("unused"),
200200
claimRelease: ()=>Effect.die("unused"),
201+
claimDeprovision: ()=>Effect.die("unused"),
201202
remove: ()=>Effect.die("unused"),
203+
removeClaimed: ()=>Effect.die("unused"),
202204
};
203205
}
204206

‎infra/relay/src/environments/EnvironmentLinker.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function testLayer(input?: {
136136
revokeForEnvironmentPublicKey: ()=>Effect.succeed(false),
137137
}),
138138
Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider,{
139+
prepareDeprovision: ()=>Effect.succeed(null),
139140
deprovision: input?.deprovision??(()=>Effect.void),
140141
release: ()=>Effect.succeed(true),
141142
provision: ()=>

‎infra/relay/src/environments/ManagedEndpointAllocations.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
1010
ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb,db)));
1111

1212
describe("ManagedEndpointAllocations",()=>{
13+
it.effect("returns a claim generation only when deprovision wins the allocation CAS",()=>{
14+
letclaimedAt: string|undefined;
15+
constfakeDb={
16+
update: (table: unknown)=>{
17+
expect(table).toBe(relayManagedEndpointAllocations);
18+
return{
19+
set: (values: {readonlyupdatedAt: string})=>{
20+
claimedAt=values.updatedAt;
21+
return{
22+
where: ()=>({
23+
returning: ()=>Effect.succeed([{userId: "user-1"}]),
24+
}),
25+
};
26+
},
27+
};
28+
},
29+
}asunknownasRelayDb.RelayDb["Service"];
30+
31+
returnEffect.gen(function*(){
32+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
33+
constgeneration=yield*allocations.claimDeprovision({
34+
userId: "user-1",
35+
environmentId: "environment-1",
36+
updatedAt: "captured-generation",
37+
});
38+
39+
expect(generation).toBe(claimedAt);
40+
expect(generation).not.toBeNull();
41+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
42+
});
43+
44+
it.effect("does not remove an allocation superseded after a deprovision claim",()=>{
45+
constfakeDb={
46+
delete: (table: unknown)=>{
47+
expect(table).toBe(relayManagedEndpointAllocations);
48+
return{
49+
where: ()=>({
50+
returning: ()=>Effect.succeed([]),
51+
}),
52+
};
53+
},
54+
}asunknownasRelayDb.RelayDb["Service"];
55+
56+
returnEffect.gen(function*(){
57+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
58+
expect(
59+
yield*allocations.removeClaimed({
60+
userId: "user-1",
61+
environmentId: "environment-1",
62+
updatedAt: "outdated-claim-generation",
63+
}),
64+
).toBe(false);
65+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
66+
});
67+
1368
it.effect("retains database failures with allocation operation and identity",()=>{
1469
constcause=newError("database unavailable");
1570
constfakeDb={

‎infra/relay/src/environments/ManagedEndpointAllocations.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
5151
"record-dns",
5252
"mark-ready",
5353
"claim-release",
54+
"claim-deprovision",
5455
"remove",
56+
"remove-claimed",
5557
]),
5658
stage: Schema.Literals(["database-request","resolve-reservation"]),
5759
userId: Schema.String,
@@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey
9193
readonlyupdatedAt: string;
9294
}
9395

96+
interfaceClaimManagedEndpointDeprovisionInputextendsManagedEndpointAllocationKey{
97+
readonlyupdatedAt: string;
98+
}
99+
100+
interfaceRemoveClaimedManagedEndpointAllocationInputextendsManagedEndpointAllocationKey{
101+
readonlyupdatedAt: string;
102+
}
103+
94104
exportclassManagedEndpointAllocationsextendsContext.Service<
95105
ManagedEndpointAllocations,
96106
{
@@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service<
119129
readonlyclaimRelease: (
120130
input: ClaimManagedEndpointReleaseInput,
121131
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
132+
/**
133+
* Claims the complete allocation for teardown only if its generation still
134+
* matches the snapshot captured by the unlink operation.
135+
*
136+
* Returns the claim generation used by `removeClaimed`, or null when a
137+
* concurrent provision has already superseded the snapshot.
138+
*/
139+
readonlyclaimDeprovision: (
140+
input: ClaimManagedEndpointDeprovisionInput,
141+
)=>Effect.Effect<string|null,ManagedEndpointAllocationPersistenceError>;
122142
readonlyremove: (
123143
input: ManagedEndpointAllocationKey,
124144
)=>Effect.Effect<void,ManagedEndpointAllocationPersistenceError>;
145+
readonlyremoveClaimed: (
146+
input: RemoveClaimedManagedEndpointAllocationInput,
147+
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
125148
}
126149
>()("t3code-relay/environments/ManagedEndpointAllocations"){}
127150

@@ -321,6 +344,35 @@ export const make = Effect.gen(function* () {
321344
);
322345
returnclaimed;
323346
}),
347+
claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function*(
348+
input: ClaimManagedEndpointDeprovisionInput,
349+
){
350+
constclaimedAt=DateTime.formatIso(yield*DateTime.now);
351+
constclaimed=yield*db
352+
.update(relayManagedEndpointAllocations)
353+
.set({updatedAt: claimedAt})
354+
.where(
355+
and(
356+
whereAllocation(input),
357+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
358+
),
359+
)
360+
.returning({userId: relayManagedEndpointAllocations.userId})
361+
.pipe(
362+
Effect.map((rows)=>rows.length>0),
363+
Effect.mapError(
364+
(cause)=>
365+
newManagedEndpointAllocationPersistenceError({
366+
operation: "claim-deprovision",
367+
stage: "database-request",
368+
userId: input.userId,
369+
environmentId: input.environmentId,
370+
cause,
371+
}),
372+
),
373+
);
374+
returnclaimed ? claimedAt : null;
375+
}),
324376
remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function*(
325377
input: ManagedEndpointAllocationKey,
326378
){
@@ -339,6 +391,32 @@ export const make = Effect.gen(function* () {
339391
),
340392
);
341393
}),
394+
removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function*(
395+
input: RemoveClaimedManagedEndpointAllocationInput,
396+
){
397+
returnyield*db
398+
.delete(relayManagedEndpointAllocations)
399+
.where(
400+
and(
401+
whereAllocation(input),
402+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
403+
),
404+
)
405+
.returning({userId: relayManagedEndpointAllocations.userId})
406+
.pipe(
407+
Effect.map((rows)=>rows.length>0),
408+
Effect.mapError(
409+
(cause)=>
410+
newManagedEndpointAllocationPersistenceError({
411+
operation: "remove-claimed",
412+
stage: "database-request",
413+
userId: input.userId,
414+
environmentId: input.environmentId,
415+
cause,
416+
}),
417+
),
418+
);
419+
}),
342420
});
343421
});
344422

‎infra/relay/src/environments/ManagedEndpointProvider.test.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ interface AllocationCall {
5050
|"recordDns"
5151
|"markReady"
5252
|"claimRelease"
53-
|"remove";
53+
|"claimDeprovision"
54+
|"remove"
55+
|"removeClaimed";
5456
readonlyinput: unknown;
5557
}
5658

@@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) {
226228
mutate(allocationKey(input),(current)=>current);
227229
returntrue;
228230
}),
231+
claimDeprovision: (input)=>
232+
Effect.sync(()=>{
233+
calls.push({operation: "claimDeprovision", input });
234+
constallocation=allocations.get(allocationKey(input));
235+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
236+
returnnull;
237+
}
238+
mutate(allocationKey(input),(current)=>current);
239+
returnallocations.get(allocationKey(input))?.updatedAt??null;
240+
}),
229241
remove: (input)=>
230242
Effect.sync(()=>{
231243
calls.push({operation: "remove", input });
232244
allocations.delete(allocationKey(input));
233245
}),
246+
removeClaimed: (input)=>
247+
Effect.sync(()=>{
248+
calls.push({operation: "removeClaimed", input });
249+
constallocation=allocations.get(allocationKey(input));
250+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
251+
returnfalse;
252+
}
253+
allocations.delete(allocationKey(input));
254+
returntrue;
255+
}),
234256
});
235257
}
236258

@@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => {
774796
"recordDns",
775797
"markReady",
776798
"get",
777-
"remove",
799+
"claimDeprovision",
800+
"removeClaimed",
778801
]);
779802
}).pipe(Effect.provide(layer));
780803
},
781804
);
782805

806+
it.effect("does not deprovision an allocation superseded by a concurrent relink",()=>{
807+
consttunnelCalls: TunnelCall[]=[];
808+
constdnsCalls: DnsCall[]=[];
809+
constallocationCalls: AllocationCall[]=[];
810+
constlayer=providerLayer(
811+
makePersistentTunnelClient(tunnelCalls),
812+
makeDnsClient(dnsCalls),
813+
makeAllocations(allocationCalls),
814+
);
815+
816+
returnEffect.gen(function*(){
817+
constprovider=yield*ManagedEndpointProvider.ManagedEndpointProvider;
818+
constkey={userId: "user_ABC",environmentId: "env_ABC"}asconst;
819+
constrequest={
820+
...key,
821+
origin: {localHttpHost: "127.0.0.1",localHttpPort: 3773},
822+
}asconst;
823+
yield*provider.provision(request);
824+
constunlinkTarget=yield*provider.prepareDeprovision(key);
825+
expect(unlinkTarget).not.toBeNull();
826+
if(unlinkTarget===null){
827+
return;
828+
}
829+
830+
// A relink refreshes the allocation generation after unlink captured its
831+
// target but before unlink begins external teardown.
832+
yield*provider.provision(request);
833+
consttunnelCallCount=tunnelCalls.length;
834+
constdnsCallCount=dnsCalls.length;
835+
constallocationCallCount=allocationCalls.length;
836+
837+
yield*provider.deprovision({ ...key,target: unlinkTarget});
838+
839+
expect(tunnelCalls).toHaveLength(tunnelCallCount);
840+
expect(dnsCalls).toHaveLength(dnsCallCount);
841+
expect(allocationCalls.slice(allocationCallCount).map((call)=>call.operation)).toEqual([
842+
"claimDeprovision",
843+
]);
844+
}).pipe(Effect.provide(layer));
845+
});
846+
783847
it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname",()=>{
784848
consttunnelCalls: TunnelCall[]=[];
785849
constdnsCalls: DnsCall[]=[];
@@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => {
10041068
"recordDns",
10051069
"markReady",
10061070
"get",
1071+
"claimDeprovision",
10071072
"get",
1008-
"remove",
1073+
"claimDeprovision",
1074+
"removeClaimed",
10091075
]);
10101076
}).pipe(Effect.provide(layer));
10111077
});
@@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => {
10461112
});
10471113
yield*provider.deprovision(key);
10481114

1049-
expect(allocationCalls.map((call)=>call.operation)).toContain("remove");
1115+
expect(allocationCalls.map((call)=>call.operation)).toContain("removeClaimed");
10501116
}).pipe(Effect.provide(layer));
10511117
});
10521118

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit baa3abd

Browse files
fix(relay): guard unlink teardown generation
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent b2203fa commit baa3abd

8 files changed

Lines changed: 318 additions & 27 deletions

‎infra/relay/src/environments/EnvironmentConnector.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ function makeAllocations(
198198
recordDns: ()=>Effect.die("unused"),
199199
markReady: ()=>Effect.die("unused"),
200200
claimRelease: ()=>Effect.die("unused"),
201+
claimDeprovision: ()=>Effect.die("unused"),
201202
remove: ()=>Effect.die("unused"),
203+
removeClaimed: ()=>Effect.die("unused"),
202204
};
203205
}
204206

‎infra/relay/src/environments/EnvironmentLinker.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ function testLayer(input?: {
136136
revokeForEnvironmentPublicKey: ()=>Effect.succeed(false),
137137
}),
138138
Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider,{
139+
prepareDeprovision: ()=>Effect.succeed(null),
139140
deprovision: input?.deprovision??(()=>Effect.void),
140141
release: ()=>Effect.succeed(true),
141142
provision: ()=>

‎infra/relay/src/environments/ManagedEndpointAllocations.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
1010
ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb,db)));
1111

1212
describe("ManagedEndpointAllocations",()=>{
13+
it.effect("returns a claim generation only when deprovision wins the allocation CAS",()=>{
14+
letclaimedAt: string|undefined;
15+
constfakeDb={
16+
update: (table: unknown)=>{
17+
expect(table).toBe(relayManagedEndpointAllocations);
18+
return{
19+
set: (values: {readonlyupdatedAt: string})=>{
20+
claimedAt=values.updatedAt;
21+
return{
22+
where: ()=>({
23+
returning: ()=>Effect.succeed([{userId: "user-1"}]),
24+
}),
25+
};
26+
},
27+
};
28+
},
29+
}asunknownasRelayDb.RelayDb["Service"];
30+
31+
returnEffect.gen(function*(){
32+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
33+
constgeneration=yield*allocations.claimDeprovision({
34+
userId: "user-1",
35+
environmentId: "environment-1",
36+
updatedAt: "captured-generation",
37+
});
38+
39+
expect(generation).toBe(claimedAt);
40+
expect(generation).not.toBeNull();
41+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
42+
});
43+
44+
it.effect("does not remove an allocation superseded after a deprovision claim",()=>{
45+
constfakeDb={
46+
delete: (table: unknown)=>{
47+
expect(table).toBe(relayManagedEndpointAllocations);
48+
return{
49+
where: ()=>({
50+
returning: ()=>Effect.succeed([]),
51+
}),
52+
};
53+
},
54+
}asunknownasRelayDb.RelayDb["Service"];
55+
56+
returnEffect.gen(function*(){
57+
constallocations=yield*ManagedEndpointAllocations.ManagedEndpointAllocations;
58+
expect(
59+
yield*allocations.removeClaimed({
60+
userId: "user-1",
61+
environmentId: "environment-1",
62+
updatedAt: "outdated-claim-generation",
63+
}),
64+
).toBe(false);
65+
}).pipe(Effect.provide(layerWithDb(fakeDb)));
66+
});
67+
1368
it.effect("retains database failures with allocation operation and identity",()=>{
1469
constcause=newError("database unavailable");
1570
constfakeDb={

‎infra/relay/src/environments/ManagedEndpointAllocations.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
5151
"record-dns",
5252
"mark-ready",
5353
"claim-release",
54+
"claim-deprovision",
5455
"remove",
56+
"remove-claimed",
5557
]),
5658
stage: Schema.Literals(["database-request","resolve-reservation"]),
5759
userId: Schema.String,
@@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey
9193
readonlyupdatedAt: string;
9294
}
9395

96+
interfaceClaimManagedEndpointDeprovisionInputextendsManagedEndpointAllocationKey{
97+
readonlyupdatedAt: string;
98+
}
99+
100+
interfaceRemoveClaimedManagedEndpointAllocationInputextendsManagedEndpointAllocationKey{
101+
readonlyupdatedAt: string;
102+
}
103+
94104
exportclassManagedEndpointAllocationsextendsContext.Service<
95105
ManagedEndpointAllocations,
96106
{
@@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service<
119129
readonlyclaimRelease: (
120130
input: ClaimManagedEndpointReleaseInput,
121131
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
132+
/**
133+
* Claims the complete allocation for teardown only if its generation still
134+
* matches the snapshot captured by the unlink operation.
135+
*
136+
* Returns the claim generation used by `removeClaimed`, or null when a
137+
* concurrent provision has already superseded the snapshot.
138+
*/
139+
readonlyclaimDeprovision: (
140+
input: ClaimManagedEndpointDeprovisionInput,
141+
)=>Effect.Effect<string|null,ManagedEndpointAllocationPersistenceError>;
122142
readonlyremove: (
123143
input: ManagedEndpointAllocationKey,
124144
)=>Effect.Effect<void,ManagedEndpointAllocationPersistenceError>;
145+
readonlyremoveClaimed: (
146+
input: RemoveClaimedManagedEndpointAllocationInput,
147+
)=>Effect.Effect<boolean,ManagedEndpointAllocationPersistenceError>;
125148
}
126149
>()("t3code-relay/environments/ManagedEndpointAllocations"){}
127150

@@ -321,6 +344,35 @@ export const make = Effect.gen(function* () {
321344
);
322345
returnclaimed;
323346
}),
347+
claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function*(
348+
input: ClaimManagedEndpointDeprovisionInput,
349+
){
350+
constclaimedAt=DateTime.formatIso(yield*DateTime.now);
351+
constclaimed=yield*db
352+
.update(relayManagedEndpointAllocations)
353+
.set({updatedAt: claimedAt})
354+
.where(
355+
and(
356+
whereAllocation(input),
357+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
358+
),
359+
)
360+
.returning({userId: relayManagedEndpointAllocations.userId})
361+
.pipe(
362+
Effect.map((rows)=>rows.length>0),
363+
Effect.mapError(
364+
(cause)=>
365+
newManagedEndpointAllocationPersistenceError({
366+
operation: "claim-deprovision",
367+
stage: "database-request",
368+
userId: input.userId,
369+
environmentId: input.environmentId,
370+
cause,
371+
}),
372+
),
373+
);
374+
returnclaimed ? claimedAt : null;
375+
}),
324376
remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function*(
325377
input: ManagedEndpointAllocationKey,
326378
){
@@ -339,6 +391,32 @@ export const make = Effect.gen(function* () {
339391
),
340392
);
341393
}),
394+
removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function*(
395+
input: RemoveClaimedManagedEndpointAllocationInput,
396+
){
397+
returnyield*db
398+
.delete(relayManagedEndpointAllocations)
399+
.where(
400+
and(
401+
whereAllocation(input),
402+
eq(relayManagedEndpointAllocations.updatedAt,input.updatedAt),
403+
),
404+
)
405+
.returning({userId: relayManagedEndpointAllocations.userId})
406+
.pipe(
407+
Effect.map((rows)=>rows.length>0),
408+
Effect.mapError(
409+
(cause)=>
410+
newManagedEndpointAllocationPersistenceError({
411+
operation: "remove-claimed",
412+
stage: "database-request",
413+
userId: input.userId,
414+
environmentId: input.environmentId,
415+
cause,
416+
}),
417+
),
418+
);
419+
}),
342420
});
343421
});
344422

‎infra/relay/src/environments/ManagedEndpointProvider.test.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ interface AllocationCall {
5050
|"recordDns"
5151
|"markReady"
5252
|"claimRelease"
53-
|"remove";
53+
|"claimDeprovision"
54+
|"remove"
55+
|"removeClaimed";
5456
readonlyinput: unknown;
5557
}
5658

@@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) {
226228
mutate(allocationKey(input),(current)=>current);
227229
returntrue;
228230
}),
231+
claimDeprovision: (input)=>
232+
Effect.sync(()=>{
233+
calls.push({operation: "claimDeprovision", input });
234+
constallocation=allocations.get(allocationKey(input));
235+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
236+
returnnull;
237+
}
238+
mutate(allocationKey(input),(current)=>current);
239+
returnallocations.get(allocationKey(input))?.updatedAt??null;
240+
}),
229241
remove: (input)=>
230242
Effect.sync(()=>{
231243
calls.push({operation: "remove", input });
232244
allocations.delete(allocationKey(input));
233245
}),
246+
removeClaimed: (input)=>
247+
Effect.sync(()=>{
248+
calls.push({operation: "removeClaimed", input });
249+
constallocation=allocations.get(allocationKey(input));
250+
if(allocation===undefined||allocation.updatedAt!==input.updatedAt){
251+
returnfalse;
252+
}
253+
allocations.delete(allocationKey(input));
254+
returntrue;
255+
}),
234256
});
235257
}
236258

@@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => {
774796
"recordDns",
775797
"markReady",
776798
"get",
777-
"remove",
799+
"claimDeprovision",
800+
"removeClaimed",
778801
]);
779802
}).pipe(Effect.provide(layer));
780803
},
781804
);
782805

806+
it.effect("does not deprovision an allocation superseded by a concurrent relink",()=>{
807+
consttunnelCalls: TunnelCall[]=[];
808+
constdnsCalls: DnsCall[]=[];
809+
constallocationCalls: AllocationCall[]=[];
810+
constlayer=providerLayer(
811+
makePersistentTunnelClient(tunnelCalls),
812+
makeDnsClient(dnsCalls),
813+
makeAllocations(allocationCalls),
814+
);
815+
816+
returnEffect.gen(function*(){
817+
constprovider=yield*ManagedEndpointProvider.ManagedEndpointProvider;
818+
constkey={userId: "user_ABC",environmentId: "env_ABC"}asconst;
819+
constrequest={
820+
...key,
821+
origin: {localHttpHost: "127.0.0.1",localHttpPort: 3773},
822+
}asconst;
823+
yield*provider.provision(request);
824+
constunlinkTarget=yield*provider.prepareDeprovision(key);
825+
expect(unlinkTarget).not.toBeNull();
826+
if(unlinkTarget===null){
827+
return;
828+
}
829+
830+
// A relink refreshes the allocation generation after unlink captured its
831+
// target but before unlink begins external teardown.
832+
yield*provider.provision(request);
833+
consttunnelCallCount=tunnelCalls.length;
834+
constdnsCallCount=dnsCalls.length;
835+
constallocationCallCount=allocationCalls.length;
836+
837+
yield*provider.deprovision({ ...key,target: unlinkTarget});
838+
839+
expect(tunnelCalls).toHaveLength(tunnelCallCount);
840+
expect(dnsCalls).toHaveLength(dnsCallCount);
841+
expect(allocationCalls.slice(allocationCallCount).map((call)=>call.operation)).toEqual([
842+
"claimDeprovision",
843+
]);
844+
}).pipe(Effect.provide(layer));
845+
});
846+
783847
it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname",()=>{
784848
consttunnelCalls: TunnelCall[]=[];
785849
constdnsCalls: DnsCall[]=[];
@@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => {
10041068
"recordDns",
10051069
"markReady",
10061070
"get",
1071+
"claimDeprovision",
10071072
"get",
1008-
"remove",
1073+
"claimDeprovision",
1074+
"removeClaimed",
10091075
]);
10101076
}).pipe(Effect.provide(layer));
10111077
});
@@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => {
10461112
});
10471113
yield*provider.deprovision(key);
10481114

1049-
expect(allocationCalls.map((call)=>call.operation)).toContain("remove");
1115+
expect(allocationCalls.map((call)=>call.operation)).toContain("removeClaimed");
10501116
}).pipe(Effect.provide(layer));
10511117
});
10521118

0 commit comments

Comments
 (0)