Skip to content

Commit 197d6eb

Browse files
committed
修复:保留分离前要求服务端准入令牌
1 parent 3aff63f commit 197d6eb

10 files changed

Lines changed: 322 additions & 53 deletions

File tree

packages/cli/src/__tests__/runtime-host-operator-command.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe('Runtime Host operator commands', () => {
209209
'access.credential.rotation.prepare',
210210
'access.credential.rotation.revoke',
211211
'host.upgrade.prepare',
212+
'hosted.execution.admit',
212213
'hosted.execution.cancel',
213214
'hosted.execution.start',
214215
],

packages/eval/harbor/relay_agent.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -189,24 +189,16 @@ async def run(self, instruction: str, environment: Any, context: Any) -> None:
189189
current = asyncio.current_task()
190190
if current is not None and hasattr(current, "uncancel"):
191191
current.uncancel()
192-
try:
193-
if request is not None and execution is not None:
194-
await self._finalize_cancelled_execution(
195-
environment,
196-
cwd,
197-
scope_path,
198-
execution,
199-
request,
200-
writer,
201-
execution_reported,
202-
)
203-
except asyncio.CancelledError:
204-
if request is not None and execution is not None:
205-
with contextlib.suppress(Exception):
206-
await _settle_or_destroy(
207-
environment, cwd, scope_path, execution, self._teardown_timeout
208-
)
209-
raise
192+
if request is not None and execution is not None:
193+
await self._cleanup_cancelled_execution(
194+
environment,
195+
cwd,
196+
scope_path,
197+
execution,
198+
request,
199+
writer,
200+
execution_reported,
201+
)
210202
raise
211203
except RelayTransportClosed:
212204
if request is not None and execution is not None:
@@ -244,6 +236,36 @@ async def run(self, instruction: str, environment: Any, context: Any) -> None:
244236
writer.close()
245237
await asyncio.wait_for(writer.wait_closed(), timeout=1)
246238

239+
async def _cleanup_cancelled_execution(
240+
self,
241+
environment: Any,
242+
cwd: str,
243+
scope_path: str,
244+
execution: asyncio.Task[Any],
245+
request: dict[str, Any],
246+
writer: Any,
247+
execution_reported: bool,
248+
) -> None:
249+
try:
250+
await self._finalize_cancelled_execution(
251+
environment,
252+
cwd,
253+
scope_path,
254+
execution,
255+
request,
256+
writer,
257+
execution_reported,
258+
)
259+
except BaseException:
260+
with contextlib.suppress(Exception):
261+
await asyncio.wait_for(
262+
_settle_or_destroy(
263+
environment, cwd, scope_path, execution, self._teardown_timeout
264+
),
265+
timeout=self._teardown_timeout,
266+
)
267+
raise
268+
247269
async def _finalize_cancelled_execution(
248270
self,
249271
environment: Any,

packages/eval/harbor/test_relay_lifecycle.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ async def exec(self, command, cwd=None, timeout_sec=None):
100100
return SimpleNamespace(return_code=0, stdout="", stderr="")
101101

102102

103+
class DummyWriter:
104+
def is_closing(self):
105+
return False
106+
107+
def write(self, _value):
108+
return None
109+
110+
async def drain(self):
111+
return None
112+
113+
103114
class ClosedWriter:
104115
def is_closing(self):
105116
return False
@@ -205,6 +216,20 @@ async def exec(self, command, cwd=None, timeout_sec=None):
205216
return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec)
206217

207218

219+
class ExplodingSubjectEnvironment:
220+
def __init__(self):
221+
self.stopped = False
222+
223+
async def exec(self, command, cwd=None, timeout_sec=None):
224+
return SimpleNamespace(return_code=0, stdout="", stderr="")
225+
226+
async def stop(self, delete=False):
227+
self.stopped = delete
228+
229+
async def upload_file(self, source, target):
230+
return None
231+
232+
208233
class SlowLeaderStopEnvironment(IgnoringLeaderEnvironment):
209234
def __init__(self):
210235
super().__init__()
@@ -235,6 +260,19 @@ async def exec(self, command, cwd=None, timeout_sec=None):
235260
return await super().exec(command, cwd=cwd, timeout_sec=timeout_sec)
236261

237262

263+
class PersistFailureEnvironment(LiveScopeEnvironment):
264+
def __init__(self):
265+
super().__init__()
266+
self.stopped = False
267+
268+
async def stop(self, delete=False):
269+
self.stopped = delete
270+
271+
async def upload_file(self, source, target):
272+
if str(target).endswith("maka-subject.stdout.txt"):
273+
raise RuntimeError("persist failed")
274+
275+
238276
class TransportLossEnvironment(SimultaneousEnvironment):
239277
async def exec(self, command, cwd=None, timeout_sec=None):
240278
if "kill -TERM" in command:
@@ -751,6 +789,87 @@ async def accept(reader, writer):
751789
server.close()
752790
await server.wait_closed()
753791

792+
async def test_execution_exception_during_host_abort_still_destroys(self):
793+
relay = load_relay()
794+
environment = ExplodingSubjectEnvironment()
795+
796+
async def boom():
797+
raise RuntimeError("subject execution exploded")
798+
799+
execution = asyncio.create_task(boom())
800+
await asyncio.sleep(0)
801+
relay.request_host_teardown()
802+
agent = relay.RelayAgent(
803+
logs_dir=Path(tempfile.gettempdir()),
804+
relay_host="127.0.0.1",
805+
relay_port=1,
806+
relay_token="token",
807+
teardown_timeout_ms=1_000,
808+
)
809+
with self.assertRaisesRegex(RuntimeError, "subject execution exploded"):
810+
await agent._cleanup_cancelled_execution(
811+
environment,
812+
"/",
813+
"/tmp/missing",
814+
execution,
815+
{"resultToken": "0" * 32, "captureStdout": True},
816+
DummyWriter(),
817+
False,
818+
)
819+
self.assertTrue(environment.stopped)
820+
821+
async def test_persistence_failure_during_host_abort_still_destroys(self):
822+
relay = load_relay()
823+
environment = PersistFailureEnvironment()
824+
token = f"host-persist-{os.getpid()}"
825+
connected = asyncio.get_running_loop().create_future()
826+
827+
async def accept(reader, writer):
828+
connected.set_result((reader, writer))
829+
830+
server = await asyncio.start_server(accept, "127.0.0.1", 0)
831+
port = server.sockets[0].getsockname()[1]
832+
agent = relay.RelayAgent(
833+
logs_dir=Path(tempfile.gettempdir()),
834+
relay_host="127.0.0.1",
835+
relay_port=port,
836+
relay_token=token,
837+
teardown_timeout_ms=1_000,
838+
)
839+
running = asyncio.create_task(agent.run("solve", environment, None))
840+
reader, writer = await connected
841+
try:
842+
await reader.readline()
843+
writer.write(
844+
(
845+
__import__("json").dumps(
846+
{
847+
"token": token,
848+
"kind": "execute",
849+
"command": "/bin/true",
850+
"args": [],
851+
"credentials": {},
852+
"resultToken": "0" * 32,
853+
}
854+
)
855+
+ "\n"
856+
).encode()
857+
)
858+
await writer.drain()
859+
environment.release.set()
860+
await environment.finished.wait()
861+
relay.request_host_teardown()
862+
running.cancel()
863+
with self.assertRaisesRegex(RuntimeError, "persist failed"):
864+
await asyncio.wait_for(running, timeout=1)
865+
self.assertTrue(
866+
any(_is_teardown(command) for command in environment.commands),
867+
)
868+
finally:
869+
writer.close()
870+
server.close()
871+
await server.wait_closed()
872+
754873
async def test_framework_timeout_stops_the_subject_without_the_process_group(self):
755874
relay = load_relay()
756875
environment = TimeoutScopeEnvironment()

packages/runtime-host/src/__tests__/hosted-execution-client.test.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -113,18 +113,21 @@ test('post-connect cancellation reports the owned Host settlement outcome', asyn
113113

114114
test('environment-preserving abort detaches without cancelling or settling the Host', async () => {
115115
const abort = new AbortController();
116-
const started = deferred();
116+
const admitted = deferred();
117117
const closed = deferred();
118118
const events: string[] = [];
119119
const connected = ownedHost({
120120
request: async (operation: string) => {
121121
events.push(operation);
122+
if (operation === 'hosted.execution.admit') {
123+
admitted.resolve();
124+
return { executionId: ID, admissionToken: ADMISSION_TOKEN };
125+
}
122126
if (operation !== 'hosted.execution.start') {
123127
throw new Error(`Unexpected operation ${operation}`);
124128
}
125-
started.resolve();
126129
await closed.promise;
127-
throw admittedInterrupt();
130+
throw dispatchedInterrupt();
128131
},
129132
});
130133
connected.connection.close = async () => {
@@ -143,29 +146,34 @@ test('environment-preserving abort detaches without cancelling or settling the H
143146
{ ...input(abort.signal), abortPolicy: 'preserve_environment' },
144147
{ connectOwnedRuntimeHost: async () => connected as never },
145148
);
146-
await started.promise;
149+
await admitted.promise;
147150
abort.abort();
148151
const result = await execution;
149152

150153
assert.equal(result.kind, 'indeterminate');
151154
assert.equal(result.failureReason, 'Hosted execution continues for environment verification');
152-
assert.deepEqual(events, ['hosted.execution.start', 'close', 'release']);
155+
assert.deepEqual(events, [
156+
'hosted.execution.admit',
157+
'close',
158+
'hosted.execution.start',
159+
'release',
160+
]);
153161
});
154162

155-
test('pre-admission preserve abort does not claim execution continues', async () => {
163+
test('frame-written admit interruption is not a server admission', async () => {
156164
const abort = new AbortController();
157165
const started = deferred();
158166
const closed = deferred();
159167
const events: string[] = [];
160168
const connected = ownedHost({
161169
request: async (operation: string) => {
162170
events.push(operation);
163-
if (operation !== 'hosted.execution.start') {
171+
if (operation !== 'hosted.execution.admit') {
164172
throw new Error(`Unexpected operation ${operation}`);
165173
}
166174
started.resolve();
167175
await closed.promise;
168-
throw queuedInterrupt();
176+
throw dispatchedInterrupt();
169177
},
170178
});
171179
connected.connection.close = async () => {
@@ -191,7 +199,7 @@ test('pre-admission preserve abort does not claim execution continues', async ()
191199

192200
assert.equal(result.kind, 'indeterminate');
193201
assert.equal(result.failureReason, 'Hosted execution was cancelled');
194-
assert.deepEqual(events, ['hosted.execution.start', 'close', 'settle']);
202+
assert.deepEqual(events, ['hosted.execution.admit', 'close', 'settle']);
195203
});
196204

197205
test('environment-preserving abort before start does not claim execution continues', async () => {
@@ -341,6 +349,7 @@ test('explicit target reconnect cancellation preserves the cancelled outcome', a
341349

342350
const ID = '00000000-0000-4000-8000-000000000001';
343351
const CONNECTION_ID = '00000000-0000-4000-8000-000000000002';
352+
const ADMISSION_TOKEN = '00000000-0000-4000-8000-0000000000ad';
344353

345354
function input(signal?: AbortSignal) {
346355
return {
@@ -477,24 +486,15 @@ function ownedHost(connection: Record<string, unknown>, clean = false) {
477486
};
478487
}
479488

480-
function admittedInterrupt() {
489+
function dispatchedInterrupt() {
481490
return new RuntimeHostRequestInterruptedError(
482-
'hosted.execution.start',
491+
'hosted.execution.admit',
483492
'command',
484493
'dispatched',
485494
'connection_lost',
486495
);
487496
}
488497

489-
function queuedInterrupt() {
490-
return new RuntimeHostRequestInterruptedError(
491-
'hosted.execution.start',
492-
'command',
493-
'not_dispatched',
494-
'connection_lost',
495-
);
496-
}
497-
498498
function deferred() {
499499
let resolve!: () => void;
500500
const promise = new Promise<void>((accept) => {

packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,32 @@ test('cancel before start prevents the hosted execution from running', async ()
4343
assert.equal(runs, 0);
4444
});
4545

46+
test('admit returns a server-owned token before start waits for settlement', async () => {
47+
const release = deferred();
48+
const coordinator = new HostHostedExecutionCoordinator(
49+
async (input) => {
50+
await release.promise;
51+
return settled(input.executionId, 'completed');
52+
},
53+
() => {},
54+
);
55+
56+
const admitted = await coordinator.handlers['hosted.execution.admit'](input(), context());
57+
assert.equal(admitted.ok, true);
58+
if (!admitted.ok) return;
59+
assert.equal(admitted.result.executionId, ID);
60+
assert.match(admitted.result.admissionToken, /^[0-9a-f-]{36}$/i);
61+
62+
const waiting = coordinator.handlers['hosted.execution.start'](input(), context());
63+
const again = await coordinator.handlers['hosted.execution.admit'](input(), context());
64+
assert.equal(again.ok, true);
65+
if (again.ok) assert.equal(again.result.admissionToken, admitted.result.admissionToken);
66+
release.resolve();
67+
const started = await waiting;
68+
assert.equal(started.ok, true);
69+
if (started.ok) assert.equal(started.result.kind, 'settled');
70+
});
71+
4672
test('cancelling a settled subject reclaims its verification environment', async () => {
4773
let drains = 0;
4874
const coordinator = new HostHostedExecutionCoordinator(
@@ -97,3 +123,11 @@ function context() {
97123
acquireResidency: () => ({ release() {} }),
98124
};
99125
}
126+
127+
function deferred() {
128+
let resolve!: () => void;
129+
const promise = new Promise<void>((accept) => {
130+
resolve = accept;
131+
});
132+
return { promise, resolve };
133+
}

packages/runtime-host/src/__tests__/protocol.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ describe('Runtime Host bootstrap protocol', () => {
9797
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 22);
9898
});
9999

100+
test('publishes a new compatibility epoch for hosted.execution.admit', () => {
101+
// Epoch 25 has start and cancel only. Mixed-version peers must fail
102+
// during handshake instead of sending an unknown admit command.
103+
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 25);
104+
});
105+
100106
test('rejects the legacy connection update result in the current compatibility epoch', () => {
101107
assert.throws(
102108
() =>

0 commit comments

Comments
 (0)