Skip to content

Commit ac6a9e8

Browse files
raphaeltmclaude
andcommitted
fix: re-read container state after wake so restored sessions accept prompts
VmAgentContainer.proxyHttp/fetch read the container state once before wakeFromSnapshot(), then applied the stopped -> 410 guard using that stale pre-wake state. A freshly-woken, restored container was rejected with 410 (surfaced by the Worker as a generic 500), so users could restore state but not continue chatting after wake. Re-read the state after a successful wake. Adds a discriminating regression test that fails on the pre-fix code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1492dbc commit ac6a9e8

2 files changed

Lines changed: 106 additions & 2 deletions

File tree

apps/api/src/durable-objects/vm-agent-container.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,18 @@ export class VmAgentContainer extends Container<Env> {
109109
}
110110

111111
async proxyHttp(request: Request, port?: number): Promise<Response> {
112-
const state = await this.getState();
112+
let state = await this.getState();
113113
const lifecycleStatus = await this.ctx.storage.get<LifecycleStatus>('lifecycleStatus');
114114
if (lifecycleStatus === 'sleeping') {
115115
const wake = await this.wakeFromSnapshot();
116116
if (!wake.ok) {
117117
return new Response(wake.message || WAKE_DEGRADED_RESPONSE, { status: 503 });
118118
}
119+
// wakeFromSnapshot launched a fresh container and restored the session.
120+
// Re-read the container state so the stopped-check below reflects the
121+
// now-running container instead of the pre-wake stopped snapshot,
122+
// otherwise a successfully-woken session is wrongly rejected with 410.
123+
state = await this.getState();
119124
}
120125
if (state.status === 'stopped' || state.status === 'stopped_with_code') {
121126
return new Response('Container is stopped; create a new instant session.', { status: 410 });
@@ -234,13 +239,16 @@ export class VmAgentContainer extends Container<Env> {
234239
}
235240

236241
override async fetch(request: Request): Promise<Response> {
237-
const state = await this.getState();
242+
let state = await this.getState();
238243
const lifecycleStatus = await this.ctx.storage.get<LifecycleStatus>('lifecycleStatus');
239244
if (lifecycleStatus === 'sleeping') {
240245
const wake = await this.wakeFromSnapshot();
241246
if (!wake.ok) {
242247
return new Response(wake.message || WAKE_DEGRADED_RESPONSE, { status: 503 });
243248
}
249+
// Re-read the container state after wake so the stopped-check reflects
250+
// the freshly-launched container, not the pre-wake stopped snapshot.
251+
state = await this.getState();
244252
}
245253
if (state.status === 'stopped' || state.status === 'stopped_with_code') {
246254
return new Response('Container is stopped; create a new instant session.', { status: 410 });
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { VmAgentContainer } from '../../../src/durable-objects/vm-agent-container';
4+
5+
// Regression test for the restored-session prompt failure: `proxyHttp` read the
6+
// container state ONCE before `wakeFromSnapshot()`, then applied the
7+
// `stopped`/`stopped_with_code` -> 410 guard using that stale pre-wake state.
8+
// A freshly-woken, restored container was therefore rejected with 410 (surfaced
9+
// by the Worker as a generic 500), even though restore succeeded. The fix
10+
// re-reads the container state after a successful wake.
11+
12+
interface FakeState {
13+
status: string;
14+
}
15+
16+
function makeFake(opts: {
17+
statuses: string[]; // sequence returned by getState()
18+
lifecycleStatus: string;
19+
wakeOk: boolean;
20+
}) {
21+
const getState = vi.fn<[], Promise<FakeState>>();
22+
for (const s of opts.statuses) {
23+
getState.mockResolvedValueOnce({ status: s });
24+
}
25+
getState.mockResolvedValue({ status: opts.statuses[opts.statuses.length - 1] });
26+
27+
const containerFetch = vi
28+
.fn()
29+
.mockResolvedValue(new Response('proxied', { status: 200 }));
30+
const wakeFromSnapshot = vi
31+
.fn()
32+
.mockResolvedValue(opts.wakeOk ? { ok: true } : { ok: false, message: 'degraded' });
33+
34+
const fake = {
35+
getState,
36+
containerFetch,
37+
wakeFromSnapshot,
38+
defaultPort: 8080,
39+
ctx: { storage: { get: vi.fn().mockResolvedValue(opts.lifecycleStatus) } },
40+
};
41+
return { fake, getState, containerFetch, wakeFromSnapshot };
42+
}
43+
44+
function callProxyHttp(fake: unknown, request: Request): Promise<Response> {
45+
return (VmAgentContainer.prototype as unknown as {
46+
proxyHttp: (this: unknown, request: Request, port?: number) => Promise<Response>;
47+
}).proxyHttp.call(fake, request);
48+
}
49+
50+
describe('VmAgentContainer.proxyHttp wake state re-read', () => {
51+
it('proxies the prompt after a successful wake even though the pre-wake state was stopped', async () => {
52+
// Pre-wake getState -> stopped; post-wake getState -> running (fresh container).
53+
const { fake, getState, containerFetch, wakeFromSnapshot } = makeFake({
54+
statuses: ['stopped', 'running'],
55+
lifecycleStatus: 'sleeping',
56+
wakeOk: true,
57+
});
58+
59+
const res = await callProxyHttp(fake, new Request('http://container/prompt', { method: 'POST' }));
60+
61+
expect(wakeFromSnapshot).toHaveBeenCalledTimes(1);
62+
// State must be re-read after wake (once before, once after) so the stopped
63+
// guard sees the now-running container.
64+
expect(getState).toHaveBeenCalledTimes(2);
65+
// The request is proxied to the running container, NOT rejected with 410.
66+
expect(containerFetch).toHaveBeenCalledTimes(1);
67+
expect(res.status).toBe(200);
68+
});
69+
70+
it('returns 503 (not 410/proxy) when wake fails', async () => {
71+
const { fake, containerFetch } = makeFake({
72+
statuses: ['stopped', 'stopped'],
73+
lifecycleStatus: 'sleeping',
74+
wakeOk: false,
75+
});
76+
77+
const res = await callProxyHttp(fake, new Request('http://container/prompt', { method: 'POST' }));
78+
79+
expect(res.status).toBe(503);
80+
expect(containerFetch).not.toHaveBeenCalled();
81+
});
82+
83+
it('still returns 410 for a genuinely stopped, non-sleeping container', async () => {
84+
const { fake, containerFetch, wakeFromSnapshot } = makeFake({
85+
statuses: ['stopped'],
86+
lifecycleStatus: 'running',
87+
wakeOk: true,
88+
});
89+
90+
const res = await callProxyHttp(fake, new Request('http://container/prompt', { method: 'POST' }));
91+
92+
expect(wakeFromSnapshot).not.toHaveBeenCalled();
93+
expect(containerFetch).not.toHaveBeenCalled();
94+
expect(res.status).toBe(410);
95+
});
96+
});

0 commit comments

Comments
 (0)