@zgeoff/atc 2.15.1 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ import { PROTOCOL_V } from '../protocol/protocol';
2
+ import type { SessionID } from '../shared/session-id';
3
+ import type { MessageOwner } from '../store/message-owner';
4
+ import type { MessageRecord } from '../store/message-record';
5
+ import type { TapClient } from './daemon-context';
6
+ import type { TapGeneration, TapRegistry } from './tap-registry';
7
+
8
+ /**
9
+ * What a drain reads from: the taps, the owner a linked tap reads messages
10
+ * under (the session's atc id and agent session id, or null for a session
11
+ * the daemon does not hold), and the store's pending messages for an owner.
12
+ */
13
+ export interface InboxSource {
14
+ readonly taps: TapRegistry<TapClient>;
15
+ readonly findLinkedOwner: (sessionID: SessionID) => MessageOwner | null;
16
+ readonly collectPendingMessages: (owner: MessageOwner) => Promise<MessageRecord[]>;
17
+ }
18
+
19
+ /**
20
+ * Hands the session's tap the oldest pending message it has not taken. The
21
+ * backlog is read before anything is sent, so a tap's ok response is always
22
+ * queued ahead of its first message, and one message goes per call: the
23
+ * tap's ack calls it again, so the backlog never outgrows the connection's
24
+ * outbound queue. The drain is bound to the tap it found before the read: a
25
+ * tap another attach made while the read waited gets nothing from it.
26
+ */
27
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the drain claims deliveries in the tap registry it is given
28
+ export async function drainInbox(sessionID: SessionID, source: InboxSource): Promise<void> {
29
+ const linkedOwner = source.findLinkedOwner(sessionID);
30
+ const tap = source.taps.findTap(sessionID);
31
+
32
+ if (linkedOwner === null || tap === null) {
33
+ return;
34
+ }
35
+
36
+ // A tap a principal holds takes only the messages sent to this atc id:
37
+ // another session that shares the agent session id may be out of its
38
+ // reach.
39
+ const owner = tap.linked ? linkedOwner : { atcID: linkedOwner.atcID };
40
+
41
+ try {
42
+ const pending = await source.collectPendingMessages(owner);
43
+
44
+ for (const record of pending) {
45
+ if (sendInboxMessage(sessionID, record, tap, source.taps)) {
46
+ return;
47
+ }
48
+ }
49
+ } catch {}
50
+ }
51
+
52
+ // Hands one pending message to the tap the drain was made for, once, and
53
+ // reports whether it did. An unlinked tap takes only a message sent to the
54
+ // session's own atc id. The event goes to the tap connection alone: it never
55
+ // reaches other clients, the events socket, or hooks.
56
+ function sendInboxMessage(
57
+ sessionID: SessionID,
58
+ record: MessageRecord,
59
+ tap: TapGeneration,
60
+
61
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a delivery claims the message in the registry
62
+ taps: TapRegistry<TapClient>,
63
+ ): boolean {
64
+ if (!tap.linked && record.atcID !== sessionID) {
65
+ return false;
66
+ }
67
+
68
+ const client = taps.claimDelivery(sessionID, record.id, tap.generation);
69
+
70
+ if (client === null) {
71
+ return false;
72
+ }
73
+
74
+ client.sendEvent({
75
+ v: PROTOCOL_V,
76
+ ev: 'InboxMessage',
77
+ s: sessionID,
78
+ message: record.id,
79
+ from: record.from,
80
+ text: record.text,
81
+ sentAt: record.sentAt,
82
+ });
83
+
84
+ return true;
85
+ }
@@ -1,14 +1,17 @@
1
1
  import { createImpClient, openExecSession, openReverseForward } from '@zgeoff/imp-client';
2
2
  import type { ExecOutcome, ImpClient } from '@zgeoff/imp-client';
3
+ import { isRecord } from '../shared/report';
3
4
  import type {
4
5
  ImpCommand,
5
6
  ImpCommandResult,
6
7
  ImpCreateSpec,
7
8
  ImpFeatures,
9
+ ImpIdentity,
8
10
  ImpLease,
9
11
  ImpPort,
10
12
  ImpRelayConnection,
11
13
  ImpReverseForward,
14
+ ImpSecret,
12
15
  ImpSessionConnection,
13
16
  ImpSessionHandlers,
14
17
  ImpSessionOutcome,
@@ -48,11 +51,79 @@ export class ImpClientPort implements ImpPort {
48
51
  this.readToken = options.readToken;
49
52
  }
50
53
 
51
- // An impd from before the flags has neither.
54
+ // A flag counts only as a literal true: an impd from before a flag has
55
+ // it false, and so does one that sends anything else in its place.
52
56
  readonly readFeatures = async (): Promise<ImpFeatures> => {
53
57
  const info = await this.tryCall((client) => client.system.info());
54
58
 
55
- return info.features ?? { sessionOffsets: false, leases: false };
59
+ const features: Readonly<Record<string, unknown>> | undefined = info.features;
60
+
61
+ return {
62
+ sessionOffsets: features?.['sessionOffsets'] === true,
63
+ leases: features?.['leases'] === true,
64
+ grantableTokens: features?.['grantableTokens'] === true,
65
+ secretRebind: features?.['secretRebind'] === true,
66
+ };
67
+ };
68
+
69
+ // An impd from before grantable lists sends none, which grants nothing.
70
+ readonly readIdentity = async (): Promise<ImpIdentity> => {
71
+ const identity = await this.tryCall((client) => client.tokens.whoami());
72
+
73
+ return {
74
+ kind: identity.kind,
75
+ name: identity.name,
76
+ scope: identity.scope,
77
+ imps: identity.imps === null ? null : [...identity.imps],
78
+ grantable: [...(identity.grantable ?? [])],
79
+ };
80
+ };
81
+
82
+ readonly readSecrets = async (): Promise<readonly ImpSecret[]> => {
83
+ const secrets = await this.tryCall((client) => client.secrets.list());
84
+
85
+ return secrets.map((secret) => ({
86
+ name: secret.name,
87
+ kind: secret.kind,
88
+ rules: secret.rules.map((rule) => ({
89
+ host: rule.host,
90
+ header: rule.header,
91
+ scheme: rule.scheme,
92
+ ...(rule.user === undefined ? {} : { user: rule.user }),
93
+ })),
94
+ imps: [...secret.imps],
95
+ }));
96
+ };
97
+
98
+ readonly readGrants = async (name: string): Promise<readonly string[]> => {
99
+ const grants = await this.tryCall((client) => client.grants.list({ name }));
100
+
101
+ return [...grants];
102
+ };
103
+
104
+ readonly createGrant = async (name: string, secret: string): Promise<void> => {
105
+ await this.tryCall((client) => client.grants.add({ name, secret }));
106
+ };
107
+
108
+ // A grant impd no longer holds is no grant to revoke; a missing imp or
109
+ // secret still rejects.
110
+ readonly removeGrant = async (name: string, secret: string): Promise<boolean> => {
111
+ try {
112
+ await this.tryCall((client) => client.grants.delete({ name, secret }));
113
+
114
+ return true;
115
+ } catch (error) {
116
+ if (
117
+ error instanceof ImpPortError &&
118
+ error.code === 'NOT_FOUND' &&
119
+ isRecord(error.data) &&
120
+ error.data['kind'] === 'grant'
121
+ ) {
122
+ return false;
123
+ }
124
+
125
+ throw error;
126
+ }
56
127
  };
57
128
 
58
129
  readonly readImp = async (name: string): Promise<ImpView | null> => {
@@ -60,6 +131,7 @@ export class ImpClientPort implements ImpPort {
60
131
  const imp = await this.tryCall((client) => client.imps.get({ name }));
61
132
 
62
133
  return {
134
+ id: imp.id,
63
135
  name: imp.name,
64
136
  state: imp.state,
65
137
  leases: (imp.leases?.leases ?? []).map((lease) => toLease(lease)),
@@ -83,7 +155,7 @@ export class ImpClientPort implements ImpPort {
83
155
  }),
84
156
  );
85
157
 
86
- return { name: imp.name, state: imp.state, leases: [], otherLeaseCount: 0 };
158
+ return { id: imp.id, name: imp.name, state: imp.state, leases: [], otherLeaseCount: 0 };
87
159
  };
88
160
 
89
161
  readonly acquireLease = async (
@@ -9,6 +9,23 @@ export interface ImpPort {
9
9
  // impd's capability flags; an impd without them has neither.
10
10
  readonly readFeatures: () => Promise<ImpFeatures>;
11
11
 
12
+ // The caller's own identity: its scope, the imps it may reach, and the
13
+ // secrets it may grant.
14
+ readonly readIdentity: () => Promise<ImpIdentity>;
15
+
16
+ // Every secret impd holds, with its rules and never its value.
17
+ readonly readSecrets: () => Promise<readonly ImpSecret[]>;
18
+
19
+ // The names of the secrets granted to an imp.
20
+ readonly readGrants: (name: string) => Promise<readonly string[]>;
21
+
22
+ // Grants a secret to an imp; granting one it already holds changes
23
+ // nothing, and nothing in the result distinguishes the two.
24
+ readonly createGrant: (name: string, secret: string) => Promise<void>;
25
+
26
+ // Revokes a secret from an imp, and reports whether impd held the grant.
27
+ readonly removeGrant: (name: string, secret: string) => Promise<boolean>;
28
+
12
29
  // The imp under a name, or null when impd holds none.
13
30
  readonly readImp: (name: string) => Promise<ImpView | null>;
14
31
  readonly createImp: (spec: ImpCreateSpec) => Promise<ImpView>;
@@ -51,11 +68,54 @@ export interface ImpPort {
51
68
  export interface ImpFeatures {
52
69
  readonly sessionOffsets: boolean;
53
70
  readonly leases: boolean;
71
+
72
+ // Tokens limited to some imps may grant a list of secrets to them.
73
+ readonly grantableTokens: boolean;
74
+
75
+ // A rebound or recreated secret drops its grants and leaves a token's
76
+ // list of grantable secrets behind.
77
+ readonly secretRebind: boolean;
78
+ }
79
+
80
+ type ImpScope = 'read' | 'exec' | 'manage';
81
+
82
+ export interface ImpIdentity {
83
+ readonly kind: 'token' | 'ssh' | 'tailnet' | 'dashboard';
84
+ readonly name: string;
85
+ readonly scope: ImpScope;
86
+
87
+ // Imp name patterns with `*` for any run of characters; null reaches
88
+ // every imp on the host.
89
+ readonly imps: readonly string[] | null;
90
+
91
+ // The secrets the caller may grant to the imps it reaches and revoke
92
+ // from them.
93
+ readonly grantable: readonly string[];
94
+ }
95
+
96
+ export interface ImpSecret {
97
+ readonly name: string;
98
+ readonly kind: 'anthropic' | 'custom' | 'github' | 'npm';
99
+ readonly rules: readonly ImpSecretRule[];
100
+
101
+ // The imps holding a grant of the secret.
102
+ readonly imps: readonly string[];
103
+ }
104
+
105
+ // How impd adds a secret to requests for one host.
106
+ export interface ImpSecretRule {
107
+ readonly host: string;
108
+ readonly header: string;
109
+ readonly scheme: 'basic' | 'bearer' | 'raw';
110
+ readonly user?: string;
54
111
  }
55
112
 
56
113
  export type ImpState = 'creating' | 'running' | 'sleeping' | 'stopped' | 'error';
57
114
 
58
115
  export interface ImpView {
116
+ // impd's id for this imp, which a new imp made under the same name never
117
+ // shares.
118
+ readonly id: string;
59
119
  readonly name: string;
60
120
  readonly state: ImpState;
61
121
 
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Whether an imp name matches one of a token's imp patterns, as impd
3
+ * matches them: each pattern is an imp name with `*` for any run of
4
+ * characters, including none, so `dev-*` matches `dev-` and `dev-a1`, and
5
+ * a pattern without `*` matches only its own name.
6
+ */
7
+ export function isImpNameAllowed(patterns: readonly string[], name: string): boolean {
8
+ return patterns.some((pattern) => isPatternMatch(pattern, name));
9
+ }
10
+
11
+ function isPatternMatch(pattern: string, name: string): boolean {
12
+ const [first = '', ...rest] = pattern.split('*');
13
+ const last = rest.pop();
14
+
15
+ if (last === undefined) {
16
+ return name === first;
17
+ }
18
+
19
+ if (name.length < first.length + last.length || !name.startsWith(first) || !name.endsWith(last)) {
20
+ return false;
21
+ }
22
+
23
+ // Each middle part in order, between the prefix and the suffix.
24
+ let at = first.length;
25
+ const end = name.length - last.length;
26
+
27
+ for (const part of rest) {
28
+ const found = name.indexOf(part, at);
29
+
30
+ if (found === -1 || found + part.length > end) {
31
+ return false;
32
+ }
33
+
34
+ at = found + part.length;
35
+ }
36
+
37
+ return true;
38
+ }
@@ -0,0 +1,19 @@
1
+ import type { AgentID } from '../shared/agent-id';
2
+
3
+ /**
4
+ * Whether a hook line, judged by the agent it carries, comes from the
5
+ * harness atc started for its session rather than from a harness nested
6
+ * inside it, which inherits the session's environment and so reports under
7
+ * the same session. A line carrying an agent is the session's own when that
8
+ * agent is the session's. A line without one comes from a hook command that
9
+ * carries no agent flag, and stays the session's own until a line carrying
10
+ * the session's agent arrives from the same terminal: from then on the
11
+ * session's own hooks are known to carry it.
12
+ */
13
+ export function isOwnHookEvent(
14
+ lineAgent: AgentID | undefined,
15
+ sessionAgent: AgentID,
16
+ hasAgentHookLines: boolean,
17
+ ): boolean {
18
+ return lineAgent === undefined ? !hasAgentHookLines : lineAgent === sessionAgent;
19
+ }
@@ -0,0 +1,32 @@
1
+ import type { SessionID } from '../shared/session-id';
2
+ import type { TargetAccess, TargetGrant } from './target-access';
3
+
4
+ // A session as the tree check reads it: its id, its parent if it has one,
5
+ // and the target it is bound to.
6
+ interface TreeMember extends TargetGrant {
7
+ readonly id: SessionID;
8
+ readonly parent: SessionID | null;
9
+ }
10
+
11
+ /**
12
+ * Whether the access reaches every session in the tree the given session
13
+ * belongs to: its top-level session and each sub-session of that one. A
14
+ * session the list does not hold, or one whose parent it does not hold,
15
+ * is out of reach.
16
+ */
17
+ export function isTreeInReach(
18
+ sessions: readonly TreeMember[],
19
+ id: SessionID,
20
+ access: TargetAccess,
21
+ ): boolean {
22
+ const session = sessions.find((x) => x.id === id);
23
+
24
+ if (session === undefined) {
25
+ return false;
26
+ }
27
+
28
+ const root = session.parent ?? session.id;
29
+ const tree = sessions.filter((x) => x.id === root || x.parent === root);
30
+
31
+ return tree.some((x) => x.id === root) && tree.every((x) => access.canUse(x));
32
+ }
@@ -23,8 +23,11 @@ export function parseHookLine(line: string): HookEvent | null {
23
23
  return null;
24
24
  }
25
25
 
26
+ const agent = parsed['agent'];
27
+
26
28
  return {
27
29
  atcId: toSessionID(parsed['atcId']),
30
+ ...(typeof agent === 'string' && agent !== '' ? { agent } : {}),
28
31
  event: parsed['event'],
29
32
  payload: parsed['payload'],
30
33
  };
@@ -46,6 +46,11 @@ export class SessionRuntime {
46
46
  // later dropped may be restarting, so it keeps the inbox open.
47
47
  tapAttached = false;
48
48
 
49
+ // Whether a hook line carrying the session's agent arrived since the
50
+ // terminal last booted. Once one has, the session's own hooks are known
51
+ // to carry it, and a line without one comes from another harness.
52
+ hasAgentHookLines = false;
53
+
49
54
  // Returns the boot-scoped state to how a fresh terminal starts, at the
50
55
  // dims it boots with: no SessionStart yet and no tap since. Every path
51
56
  // that boots a new terminal for an existing session runs it, so a revived
@@ -54,6 +59,7 @@ export class SessionRuntime {
54
59
  this.dims = dims;
55
60
  this.startedAt = null;
56
61
  this.tapAttached = false;
62
+ this.hasAgentHookLines = false;
57
63
  }
58
64
 
59
65
  dispose(): void {
@@ -479,8 +479,15 @@ export class SessionManager {
479
479
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
480
480
  // same agent session id. On a remote host the host wakes first, and a
481
481
  // harness still running inside it is attached rather than started again;
482
- // every other session left asleep on that host comes back with it.
483
- async adoptTerminal(id: SessionID, cols: number, rows: number): Promise<Session | null> {
482
+ // every other session left asleep on that host comes back with it. The
483
+ // caller's check runs again after each await, and a failed check leaves
484
+ // the session as it was.
485
+ async adoptTerminal(
486
+ id: SessionID,
487
+ cols: number,
488
+ rows: number,
489
+ canProceed: () => boolean = () => true,
490
+ ): Promise<Session | null> {
484
491
  const s = this.sessions.find((x) => x.id === id);
485
492
 
486
493
  if (!s || s.pty !== null || s.agentSessionID === undefined || this.adopting.has(id)) {
@@ -489,7 +496,7 @@ export class SessionManager {
489
496
 
490
497
  const settled = await this.isRollbackSettled(id);
491
498
 
492
- if (!settled) {
499
+ if (!settled || !canProceed()) {
493
500
  return null;
494
501
  }
495
502
 
@@ -516,9 +523,11 @@ export class SessionManager {
516
523
  this.adopting.delete(id);
517
524
  }
518
525
 
519
- // A kill, a second adopt, or a failed spawn's rollback can land while
520
- // the host wakes.
526
+ // A kill, a second adopt, a failed spawn's rollback, or a change that
527
+ // takes the session out of the caller's reach can land while the host
528
+ // wakes.
521
529
  if (
530
+ !canProceed() ||
522
531
  s.pty !== null ||
523
532
  !this.sessions.includes(s) ||
524
533
  this.rollingBack.has(id) ||
@@ -729,6 +738,7 @@ export class SessionManager {
729
738
  id: SessionID = mintSessionID(),
730
739
  target = 'local',
731
740
  materialized: MaterializedSpawn | null = null,
741
+ requireInReach: () => void = () => {},
732
742
  ): Promise<Session> {
733
743
  const adapter = this.findAdapter(agent);
734
744
 
@@ -751,6 +761,10 @@ export class SessionManager {
751
761
  ...overrides,
752
762
  });
753
763
 
764
+ // The caller's check runs again once the host is ready, before the
765
+ // harness starts.
766
+ requireInReach();
767
+
754
768
  const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
755
769
 
756
770
  const pty = provider.spawnHarness({
@@ -1248,7 +1262,8 @@ export class SessionManager {
1248
1262
  // every harness on it kept inside, and fails whole when the host stays
1249
1263
  // awake. A dead session on a target that can destroy its host is not
1250
1264
  // forgotten by a kill: forgetting it destroys the host, which takes a
1251
- // confirmed forget.
1265
+ // confirmed forget. The set is taken before the first await, so a
1266
+ // sub-session spawned while the kill waits on a host is not part of it.
1252
1267
  async kill(id: SessionID): Promise<void> {
1253
1268
  const s = this.sessions.find((x) => x.id === id);
1254
1269
 
@@ -1256,11 +1271,15 @@ export class SessionManager {
1256
1271
  return;
1257
1272
  }
1258
1273
 
1274
+ const children = this.collectChildren(id);
1275
+
1259
1276
  if (s.pty) {
1260
1277
  await this.stopHarness(s);
1261
1278
 
1262
- for (const child of this.collectChildren(id)) {
1263
- await this.tryStopHarness(child);
1279
+ for (const child of children) {
1280
+ if (this.sessions.includes(child) && child.parent === id) {
1281
+ await this.tryStopHarness(child);
1282
+ }
1264
1283
  }
1265
1284
  } else {
1266
1285
  if (this.findProvider(s)?.capabilities.destroy === true) {
@@ -1271,8 +1290,7 @@ export class SessionManager {
1271
1290
  );
1272
1291
  }
1273
1292
 
1274
- this.updateForgottenChildren(id);
1275
- this.remove(s);
1293
+ this.removeWithChildren(s, children);
1276
1294
  }
1277
1295
 
1278
1296
  await this.writeFleet();
@@ -1288,7 +1306,9 @@ export class SessionManager {
1288
1306
  * kept asleep in that host is refused until the host wakes. Its dead
1289
1307
  * sub-sessions on other hosts go with it, unless their own target can
1290
1308
  * destroy their host, and its live ones become top-level. A failed destroy
1291
- * throws before anything is forgotten.
1309
+ * throws before anything is forgotten. The sub-sessions it may forget are
1310
+ * taken before the first await; one spawned while the forget waits on a
1311
+ * host becomes top-level.
1292
1312
  */
1293
1313
  async forget(id: SessionID): Promise<boolean> {
1294
1314
  const s = this.sessions.find((x) => x.id === id);
@@ -1297,6 +1317,7 @@ export class SessionManager {
1297
1317
  return false;
1298
1318
  }
1299
1319
 
1320
+ const children = this.collectChildren(id);
1300
1321
  const provider = this.findProvider(s);
1301
1322
  const destroys = provider !== null && provider.capabilities.destroy && s.hostKey === s.id;
1302
1323
 
@@ -1330,8 +1351,7 @@ export class SessionManager {
1330
1351
  this.killTerminal(s);
1331
1352
  }
1332
1353
 
1333
- this.updateForgottenChildren(id);
1334
- this.remove(s);
1354
+ this.removeWithChildren(s, children);
1335
1355
 
1336
1356
  await this.writeFleet();
1337
1357
 
@@ -1340,22 +1360,42 @@ export class SessionManager {
1340
1360
  return destroys;
1341
1361
  }
1342
1362
 
1343
- // A forgotten parent's dead sub-sessions go with it, except one whose own
1344
- // target can destroy its host: forgetting that one destroys the host, which
1345
- // takes its own confirmed forget. Every sub-session that stays becomes
1346
- // top-level.
1347
- private updateForgottenChildren(id: SessionID): void {
1348
- for (const child of this.collectChildren(id)) {
1363
+ // Removes a forgotten session and its dead sub-sessions, except one whose
1364
+ // own target can destroy its host: forgetting that one destroys the host,
1365
+ // which takes its own confirmed forget. Only a sub-session among the given
1366
+ // ones may go. Every sub-session that stays becomes top-level before the
1367
+ // session's removal is announced, and is announced after it, so no
1368
+ // announcement ever finds a sub-session whose parent is gone or a parent
1369
+ // standing without the sub-sessions that leave with it.
1370
+ private removeWithChildren(s: Session, forgettable: readonly Session[]): void {
1371
+ const kept: Session[] = [];
1372
+ const dropped: Session[] = [];
1373
+
1374
+ for (const child of this.collectChildren(s.id)) {
1349
1375
  const live = child.pty !== null || (child.kind === 'headless' && child.state !== 'exited');
1350
1376
 
1351
- if (live || this.findProvider(child)?.capabilities.destroy === true) {
1377
+ if (
1378
+ live ||
1379
+ !forgettable.includes(child) ||
1380
+ this.findProvider(child)?.capabilities.destroy === true
1381
+ ) {
1352
1382
  child.parent = null;
1353
1383
 
1354
- this.onEvent('state', child);
1384
+ kept.push(child);
1355
1385
  } else {
1356
- this.remove(child);
1386
+ dropped.push(child);
1357
1387
  }
1358
1388
  }
1389
+
1390
+ this.remove(s);
1391
+
1392
+ for (const child of kept) {
1393
+ this.onEvent('state', child);
1394
+ }
1395
+
1396
+ for (const child of dropped) {
1397
+ this.remove(child);
1398
+ }
1359
1399
  }
1360
1400
 
1361
1401
  /**
@@ -4,6 +4,23 @@ import type { SessionID } from '../shared/session-id';
4
4
  interface TapEntry<TClient> {
5
5
  readonly client: TClient;
6
6
  readonly claimed: Set<MessageID>;
7
+
8
+ // Whether the tap also takes messages held under the session's agent
9
+ // session id, as opposed to its atc id alone.
10
+ readonly linked: boolean;
11
+
12
+ // Which attach made this tap; every attach makes a new one.
13
+ readonly generation: number;
14
+ }
15
+
16
+ /**
17
+ * A session's tap as a drain finds it before it waits: the attach that made
18
+ * it and whether it is linked. A delivery made for it lands only while the
19
+ * same attach holds the tap.
20
+ */
21
+ export interface TapGeneration {
22
+ readonly generation: number;
23
+ readonly linked: boolean;
7
24
  }
8
25
 
9
26
  /**
@@ -13,14 +30,24 @@ interface TapEntry<TClient> {
13
30
  export class TapRegistry<TClient> {
14
31
  private readonly bySession = new Map<SessionID, TapEntry<TClient>>();
15
32
 
33
+ private nextGeneration = 0;
34
+
16
35
  /**
17
36
  * Makes the client the session's tap and returns the client it displaced,
18
- * or null when there was none or it is the same connection.
37
+ * or null when there was none or it is the same connection. A linked tap
38
+ * also takes the messages held under the session's agent session id.
19
39
  */
20
- attach(sessionID: SessionID, client: TClient): TClient | null {
40
+ attach(sessionID: SessionID, client: TClient, linked = true): TClient | null {
21
41
  const previous = this.bySession.get(sessionID)?.client ?? null;
22
42
 
23
- this.bySession.set(sessionID, { client, claimed: new Set() });
43
+ this.nextGeneration += 1;
44
+
45
+ this.bySession.set(sessionID, {
46
+ client,
47
+ claimed: new Set(),
48
+ linked,
49
+ generation: this.nextGeneration,
50
+ });
24
51
 
25
52
  return previous === client ? null : previous;
26
53
  }
@@ -45,6 +72,12 @@ export class TapRegistry<TClient> {
45
72
  return previous;
46
73
  }
47
74
 
75
+ findTap(sessionID: SessionID): TapGeneration | null {
76
+ const entry = this.bySession.get(sessionID);
77
+
78
+ return entry === undefined ? null : { generation: entry.generation, linked: entry.linked };
79
+ }
80
+
48
81
  hasTap(sessionID: SessionID): boolean {
49
82
  return this.bySession.has(sessionID);
50
83
  }
@@ -53,10 +86,15 @@ export class TapRegistry<TClient> {
53
86
  return this.bySession.get(sessionID)?.client === client;
54
87
  }
55
88
 
56
- claimDelivery(sessionID: SessionID, messageID: MessageID): TClient | null {
89
+ /**
90
+ * Claims the message for the session's tap and returns its client, or
91
+ * null when the tap already took it or another attach replaced the tap
92
+ * the delivery was made for.
93
+ */
94
+ claimDelivery(sessionID: SessionID, messageID: MessageID, generation: number): TClient | null {
57
95
  const entry = this.bySession.get(sessionID);
58
96
 
59
- if (entry === undefined || entry.claimed.has(messageID)) {
97
+ if (entry === undefined || entry.generation !== generation || entry.claimed.has(messageID)) {
60
98
  return null;
61
99
  }
62
100