Skip to content

Commit 7fcb8f0

Browse files
committed
feat(golang)!: the crypto guest steps instead of blocking on transport_send, and the client drives it outside its lock
The crypto guest no longer imports transport_send. An export that can reach ZeroKMS (se_cipher_init, se_keyset, se_term, se_encrypt_record, se_decrypt_record, se_query_term) runs its work through the step loop: done, it returns its output as before; waiting on a request, it returns STATUS_NEEDS_IO, the host takes the handle and requests with se_io, performs them and calls se_resume with the responses, or gives up with se_abort. A step and the responses are transport frames read and written with serde (the guest's io module). Each paused operation owns its inputs, plaintext included, and shares the cipher through an Rc, so se_shutdown drops every paused operation before the cipher and its wipe is the last owner's. A second se_cipher_init is refused while one is paused. The Go client drives the loop. It holds its lock for each step only, and performs the requests with the lock free, so other callers run meanwhile instead of queuing behind a round trip. Each step runs under a context that cannot be cancelled, so the runtime never closes the module under a caller who gave up; the context is checked between steps, and an operation whose caller has gone is aborted in the guest. The token is fetched before each step, outside the lock, and token_get only hands it over. Steps of different operations may interleave; a step is read from the guest before the lock is released. Tests: the import surface is token_get alone; one load-keyset request is one step and spends its handle; a call runs while another's request is in flight; a caller that gives up mid-request gets its context's error, the operation is gone and the client still works; an ended context never starts a call; the token is fetched outside the lock; an init paused and aborted leaves neither the client key nor the bearer token in guest memory (and without the abort, the bearer remains, so the test can fail). Refs #1064.
1 parent 592b66b commit 7fcb8f0

25 files changed

Lines changed: 1260 additions & 257 deletions

‎languages/golang/CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`.
9393
open. A client or profile still closes itself (`ErrState`) when the
9494
guest traps, or when a call runs on for 30 seconds after its context
9595
ended.
96+
- **A call no longer holds the client across a ZeroKMS round trip.** The
97+
guest returns each ZeroKMS request to the client instead of performing it
98+
through a blocking host import (`transport_send` is gone; the guest's one
99+
import is `token_get`). The client performs the request with its lock
100+
free, so other callers run meanwhile, then resumes the call. A call whose
101+
context ends between those steps returns the context's error, matching
102+
`ErrTransport` too, and the guest drops the paused call, wiping what it
103+
held; the client stays open.
104+
The token is fetched once per call, before its first step and outside
105+
the lock. This is the step loop an edge runtime's host needs, where
106+
`fetch` cannot block.
96107
- **stashgen refuses a match index on an index-only field**
97108
(`stash:"name,index=match"` with no `encrypt`). A match query's rows are
98109
checked by decrypting them, and such a field stores no ciphertext.

‎languages/golang/encrypt/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,11 @@ that stores separate byte columns (`index=`) against real ZeroKMS.
102102

103103
A `Client` is one ZeroKMS client: its client key, its default keyset, and the
104104
keysets it has loaded since. Make one per process and share it; it is safe
105-
for concurrent use.
105+
for concurrent use. The guest runs one call at a time, but never holds the
106+
client across a ZeroKMS round trip: a call that needs ZeroKMS hands its
107+
request to the client, which performs it with the client free for other
108+
callers, and then carries on. A call whose context ends while its request
109+
is in flight returns the context's error and leaves the client open.
106110

107111
```go
108112
client, err := encrypt.NewClient(ctx)

‎languages/golang/encrypt/cancel_test.go‎

Lines changed: 114 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ func (s *switchable) RoundTrip(r *http.Request) (*http.Response, error) {
5757

5858
// cancelClient is a guest not yet given a key, whose ZeroKMS requests go to
5959
// rt and whose token comes from token. Its se_cipher_init is the call the
60-
// tests drive: it makes a load-keyset request, so it reaches the network.
60+
// tests drive: it pauses on a load-keyset request, so it reaches the
61+
// network between two steps.
6162
func cancelClient(t *testing.T, rt http.RoundTripper, token tokenSource) *Client {
6263
t.Helper()
6364
tr := &transport{rt: rt, token: token}
@@ -77,7 +78,7 @@ func cipherInit(ctx context.Context, c *Client) error {
7778
return err
7879
}
7980
defer wipe(encoded)
80-
_, err = c.call(ctx, func(ctx context.Context, inst *instance) ([]byte, error) {
81+
_, err = c.callKMS(ctx, func(ctx context.Context, inst *instance) ([]byte, error) {
8182
return inst.call(ctx, inst.cipherInit, buf(encoded))
8283
})
8384
return err
@@ -96,21 +97,28 @@ func wantOpen(t *testing.T, c *Client, rt *switchable) {
9697
}
9798
}
9899

99-
func TestACallCancelledDuringARequestFailsOnlyItself(t *testing.T) {
100+
// The request runs between two steps, under the caller's context, with the
101+
// guest paused: cancelling it ends the call and aborts the paused
102+
// operation, and the client stays open.
103+
func TestACallCancelledBetweenStepsFailsOnlyItself(t *testing.T) {
104+
ctx, cancel := context.WithCancel(context.Background())
105+
defer cancel()
100106
rt := &switchable{rt: func(r *http.Request) (*http.Response, error) {
107+
// The caller gives up once its request is in flight.
108+
cancel()
101109
<-r.Context().Done()
102110
return nil, r.Context().Err()
103111
}}
104112
c := cancelClient(t, rt, staticToken("t"))
105-
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
106-
defer cancel()
107113
err := cipherInit(ctx, c)
108-
if !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, ErrTransport) {
109-
t.Fatalf("cancelled call: %v, want the deadline and ErrTransport", err)
114+
if !errors.Is(err, context.Canceled) || !errors.Is(err, ErrTransport) {
115+
t.Fatalf("cancelled call: %v, want context.Canceled and ErrTransport", err)
110116
}
111117
wantOpen(t, c, rt)
112118
}
113119

120+
// The token is fetched before the operation's first step: a caller that
121+
// gives up waiting on it never starts the operation.
114122
func TestACallCancelledDuringATokenFetchFailsOnlyItself(t *testing.T) {
115123
rt := &switchable{rt: unauthorized}
116124
var blocking atomic.Bool
@@ -126,8 +134,8 @@ func TestACallCancelledDuringATokenFetchFailsOnlyItself(t *testing.T) {
126134
ctx, cancel := context.WithCancel(context.Background())
127135
time.AfterFunc(50*time.Millisecond, cancel)
128136
err := cipherInit(ctx, c)
129-
if !errors.Is(err, context.Canceled) || !errors.Is(err, ErrTransport) {
130-
t.Fatalf("cancelled call: %v, want context.Canceled and ErrTransport", err)
137+
if !errors.Is(err, context.Canceled) || errors.Is(err, ErrState) {
138+
t.Fatalf("cancelled call: %v, want context.Canceled", err)
131139
}
132140
if n := rt.sent.Load(); n != 0 {
133141
t.Fatalf("%d request(s) sent without a token", n)
@@ -175,31 +183,53 @@ func TestACallWhoseContextEndsInTheQueueNeverReachesTheGuest(t *testing.T) {
175183
wantOpen(t, c, rt)
176184
}
177185

178-
// The backstop: a call still running long after its caller gave up is a
186+
// The backstop: a step still running long after its caller gave up is a
179187
// guest or host function that ignores cancellation. Its state is unknown,
180-
// so the client closes, and says so.
181-
func TestACallThatRunsOnAfterItsContextEndsClosesTheClient(t *testing.T) {
182-
rt := &switchable{rt: func(*http.Request) (*http.Response, error) {
183-
// A RoundTripper that ignores the request's context.
184-
time.Sleep(300 * time.Millisecond)
185-
return unauthorized(nil)
186-
}}
188+
// so the client closes, and says so. The step here waits out its caller's
189+
// context and the backstop and only then enters the guest, as a guest
190+
// stuck in a loop would still be running when the backstop fired.
191+
func TestAStepThatRunsOnAfterItsContextEndsClosesTheClient(t *testing.T) {
192+
rt := &switchable{rt: unauthorized}
187193
c := cancelClient(t, rt, staticToken("t"))
188194
c.stuck = 20 * time.Millisecond
189-
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
195+
ctx, cancel := context.WithCancel(context.Background())
190196
defer cancel()
191-
err := cipherInit(ctx, c)
197+
_, err := c.call(ctx, func(run context.Context, inst *instance) ([]byte, error) {
198+
cancel()
199+
<-run.Done()
200+
return inst.call(run, inst.targets)
201+
})
192202
if err == nil || !strings.Contains(err.Error(), "the client is closed") {
193203
t.Fatalf("stuck call: %v, want the client closed", err)
194204
}
195-
if !errors.Is(err, context.DeadlineExceeded) {
196-
t.Errorf("stuck call: %v, want it to match the deadline", err)
205+
if !errors.Is(err, context.Canceled) {
206+
t.Errorf("stuck call: %v, want it to match context.Canceled", err)
197207
}
198208
if err := cipherInit(context.Background(), c); !errors.Is(err, ErrState) {
199209
t.Fatalf("a call after the close: %v, want ErrState", err)
200210
}
201211
}
202212

213+
// A request that ignores its context holds up only its own call: the guest
214+
// is paused while it runs, so nothing is stuck, and the client stays open.
215+
func TestARequestThatIgnoresItsContextLeavesTheClientOpen(t *testing.T) {
216+
ctx, cancel := context.WithCancel(context.Background())
217+
defer cancel()
218+
rt := &switchable{rt: func(*http.Request) (*http.Response, error) {
219+
// The caller gives up once its request is in flight, and the
220+
// request runs on past the backstop regardless.
221+
cancel()
222+
time.Sleep(100 * time.Millisecond)
223+
return unauthorized(nil)
224+
}}
225+
c := cancelClient(t, rt, staticToken("t"))
226+
c.stuck = 20 * time.Millisecond
227+
if err := cipherInit(ctx, c); !errors.Is(err, context.Canceled) || !errors.Is(err, ErrTransport) {
228+
t.Fatalf("call: %v, want context.Canceled and ErrTransport", err)
229+
}
230+
wantOpen(t, c, rt)
231+
}
232+
203233
// A call that finishes in time stops the backstop: the client stays open
204234
// however long after the call its context ends.
205235
func TestAFinishedCallStopsTheBackstop(t *testing.T) {
@@ -236,6 +266,67 @@ func TestACallThatSucceedsAfterItsContextEndsFailsWithIt(t *testing.T) {
236266
wantOpen(t, c, rt)
237267
}
238268

269+
// A step that pauses after its caller gave up sends no request: the
270+
// operation is aborted in the guest, so its handle is spent, and the call
271+
// fails with the context's error.
272+
func TestAStepThatPausesAfterItsContextEndsSendsNothing(t *testing.T) {
273+
rt := &switchable{rt: unauthorized}
274+
c := cancelClient(t, rt, staticToken("t"))
275+
encoded, err := encodeConfig(testInit("http://zerokms.invalid"))
276+
if err != nil {
277+
t.Fatal(err)
278+
}
279+
ctx, cancel := context.WithCancel(context.Background())
280+
_, err = c.callKMS(ctx, func(run context.Context, inst *instance) ([]byte, error) {
281+
cancel()
282+
return inst.call(run, inst.cipherInit, buf(encoded))
283+
})
284+
if !errors.Is(err, context.Canceled) || !errors.Is(err, ErrTransport) {
285+
t.Fatalf("call: %v, want context.Canceled and ErrTransport", err)
286+
}
287+
if n := rt.sent.Load(); n != 0 {
288+
t.Fatalf("%d request(s) sent for a caller that had given up", n)
289+
}
290+
if _, err := c.inst.call(context.Background(), c.inst.abort, scalar(1)); !errors.Is(err, ErrState) {
291+
t.Fatalf("the paused operation's handle: %v, want ErrState (already dropped)", err)
292+
}
293+
wantOpen(t, c, rt)
294+
}
295+
296+
// A caller whose context ends while its resume waits for the client never
297+
// resumes: the paused operation is still aborted in the guest once the
298+
// client is free.
299+
func TestAResumeWhoseContextEndsInTheQueueAbortsTheOperation(t *testing.T) {
300+
stub := newHeldStub(t)
301+
c := stepClient(t, staticToken("stub-token"))
302+
ctx, cancel := context.WithCancel(context.Background())
303+
defer cancel()
304+
done := make(chan error, 1)
305+
go func() {
306+
_, err := c.callKMS(ctx, initStep(t, stub.URL))
307+
done <- err
308+
}()
309+
waitFor(t, stub.arrived, "the init's request")
310+
// Another call holds the client when the response comes back.
311+
if err := c.mu.Acquire(context.Background()); err != nil {
312+
t.Fatal(err)
313+
}
314+
close(stub.release)
315+
time.Sleep(50 * time.Millisecond)
316+
cancel()
317+
time.Sleep(20 * time.Millisecond)
318+
c.mu.Unlock()
319+
if err := <-done; !errors.Is(err, context.Canceled) {
320+
t.Fatalf("call: %v, want context.Canceled", err)
321+
}
322+
if _, err := c.inst.call(context.Background(), c.inst.abort, scalar(1)); !errors.Is(err, ErrState) {
323+
t.Fatalf("the paused operation's handle: %v, want ErrState (already dropped)", err)
324+
}
325+
if c.closed || c.inst.module.IsClosed() {
326+
t.Fatal("giving up closed the client")
327+
}
328+
}
329+
239330
// A closed module is ErrState, and Close still releases the runtime.
240331
func TestAClosedModuleIsState(t *testing.T) {
241332
ctx := context.Background()
@@ -382,8 +473,8 @@ func TestACallCancelledDuringATokenExchangeLeavesTheProfileOpen(t *testing.T) {
382473
hold.Store(true)
383474
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
384475
defer cancel()
385-
if err := cipherInit(ctx, c); !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, ErrTransport) {
386-
t.Fatalf("call cancelled during the exchange: %v, want the deadline and ErrTransport", err)
476+
if err := cipherInit(ctx, c); !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrState) {
477+
t.Fatalf("call cancelled during the exchange: %v, want the deadline", err)
387478
}
388479
hold.Store(false)
389480
wantOpen(t, c, rt)

0 commit comments

Comments
 (0)