q.Open and q.OpenE¶
Resource acquisition with defer-on-success cleanup — the (T, error) bubble plus a registered defer cleanup(resource) on the success path.
Signatures¶
func Open[T any](v T, err error) OpenResult[T]
func OpenE[T any](v T, err error) OpenResultE[T]
func (OpenResult[T]) DeferCleanup(cleanup ...func(T)) T // 0 or 1 args
func (OpenResult[T]) NoDeferCleanup() T
func (OpenResultE[T]) DeferCleanup(cleanup ...func(T)) T // 0 or 1 args
func (OpenResultE[T]) NoDeferCleanup() T
.DeferCleanup(...) and .NoDeferCleanup() are both terminals — what actually returns T. q.Open(v, err) on its own returns an OpenResult[T] that exposes nothing else; you always chain one of the two terminals onto it. (Why a method, not an extra arg: Go's multi-return spread only fires when the multi-return call is the sole argument, so q.Open(call(), cleanup) won't compile. The terminal-method shape side-steps that.)
.DeferCleanup accepts zero or one cleanup function:
DeferCleanup(cleanup)— explicit cleanup, used for any T.DeferCleanup()— no args; the preprocessor infers the cleanup from T at compile time.
What q.Open does¶
rewrites to:
conn, _qErr1 := dial(addr)
if _qErr1 != nil {
return /* zeros */, _qErr1
}
defer ((*Conn).Close)(conn)
On error: bubble, no cleanup registered (nothing was acquired). On success: register the deferred cleanup so it fires when the enclosing function returns (whether via normal return or via a later bubble).
.DeferCleanup() (zero args) — preprocessor infers the cleanup¶
For the common case (a Closer-shaped resource or a channel), let the preprocessor figure it out:
ch := q.Open(makeChan()).DeferCleanup() // → defer close(ch)
file := q.Open(os.Open(path)).DeferCleanup() // → defer func() { _ = file.Close() }()
db := q.Open(sql.Open(...)).DeferCleanup() // → defer func() { _ = db.Close() }()
The typecheck pass inspects the resource type T at compile time and dispatches:
| T's shape | Generated defer |
|---|---|
channel type (chan X, chan<- X, <-chan X) |
defer close(v) |
Close() error method |
defer func() { _ = v.Close() }() (close-time error discarded — pass an explicit cleanup if you need to handle it) |
Close() method (no return) |
defer v.Close() |
| anything else | build error — pass an explicit cleanup or .NoDeferCleanup() |
Auto-cleanup composes with the OpenE shape methods:
If the type doesn't expose a recognised cleanup shape (no Close, not a channel), the preprocessor surfaces a file:line:col: q: … diagnostic naming the type and pointing at the two acceptable fixes (explicit cleanup function or .NoDeferCleanup()).
.NoDeferCleanup() — opt-in "no cleanup" terminal¶
Some resources don't need a cleanup at the call site — for example, you might be passing the value off to a long-lived owner that handles teardown elsewhere, or the type genuinely has nothing to release. Spell that intent explicitly with .NoDeferCleanup():
val := q.Open(loadValue(key)).NoDeferCleanup()
// rewrites to:
// val, _qErr1 := loadValue(key)
// if _qErr1 != nil { return /* zeros */, _qErr1 }
// // no defer
.NoDeferCleanup() shares the bubble path with .DeferCleanup(...) — only the success-defer line is omitted. Composes with the OpenE shape methods just like Release does:
Why a separate terminal instead of DeferCleanup(q.NoDeferCleanup) (a no-op cleanup)? Spelling it as a method makes the intent obvious in code review — "we acquired this and we're not closing it, here's the call that says so" — instead of needing to look up what q.NoDeferCleanup does in the docs.
Chain methods on q.OpenE¶
All of these return OpenResultE[T] so .DeferCleanup can still terminate the chain.
| Method | Bubbled error |
|---|---|
.Err(replacement error) |
replacement |
.ErrF(fn func(error) error) |
fn(capturedErr) |
.Wrap(msg string) |
fmt.Errorf("<msg>: %w", capturedErr) |
.Wrapf(format string, args ...any) |
fmt.Errorf("<format>: %w", args..., capturedErr) |
.Catch(fn func(error) (T, error)) |
On recover (v, nil) the recovered v is what .DeferCleanup's cleanup later fires on |
Example chain:
and with recovery:
conn := q.OpenE(dial(addr)).Catch(func(e error) (*Conn, error) {
if errors.Is(e, syscall.ECONNREFUSED) {
return fallbackConn(), nil // recovered resource feeds DeferCleanup's cleanup
}
return nil, e
}).DeferCleanup((*Conn).Close)
LIFO cleanup across multiple Opens¶
Two Opens in sequence register two defers. Go defer semantics are LIFO, so the later-acquired resource is released first — matching the defer lock.Unlock() idiom scaled up:
func work(addr, path string) error {
conn := q.Open(dial(addr)).DeferCleanup((*Conn).Close)
f := q.Open(os.Open(path)).DeferCleanup((*os.File).Close)
// f.Close() runs first (innermost defer), then conn.Close().
return process(conn, f)
}
If os.Open fails above, conn has already been acquired — its cleanup runs, f's does not.
Statement forms¶
Works in every position q.Try does:
conn := q.Open(dial()).DeferCleanup(cleanup) // define
conn = q.Open(dial()).DeferCleanup(cleanup) // assign
q.Open(dial()).DeferCleanup(cleanup) // discard (side-effect only — cleanup registers)
return q.Open(dial()).DeferCleanup(cleanup), nil // return-position
id := identify(q.Open(dial()).DeferCleanup(cleanup)) // hoist
Resource-escape detection¶
A q.Open(...).DeferCleanup(...) value is alive only until the enclosing function returns — that's when the deferred cleanup fires. Letting the value escape that scope is a use-after-close in waiting. The preprocessor catches the obvious shapes and fails the build with a diagnostic.
Three death events make a binding "dead" from a given source point onward:
q.Open(...).DeferCleanup(...)itself — auto-defers cleanup. Dead from the assign line.defer x.Close()— explicit user-written defer. Dead from the defer line.x.Close()— synchronous close. Dead from this point onward.
Note that close(ch) and defer close(ch) are NOT death events. Receiving from a closed channel is idiomatic (close(ch); return ch is a legitimate finite-channel factory), so channels are exempt.
Once dead, the binding cannot escape via:
return cgo fn(c)/defer fn(c)(other than the cleanup itself)- field / global / map / index store:
p.c = c,m[k] = c, etc. - channel send:
ch <- c
One-hop alias tracking covers the common c2 := c; return c2 shape. Deeper indirection (passing through function calls, returning from inner closures) is out of scope — flag-everything-that-might-escape produces too many false positives without a real flow analysis.
q.Open(...).NoDeferCleanup() is the explicit "caller takes ownership" form and never makes the binding dead — return it freely.
//q:no-escape-check opt-out¶
Some test fixtures (notably tests of q.Open's mechanism itself) intentionally factory out a closed resource so the caller can probe its post-close state. Mark such functions with a //q:no-escape-check directive:
//q:no-escape-check
func channelAutoInner() (chan int, error) {
ch := q.Open(makeChan()).DeferCleanup()
ch <- 7
return ch, nil
}
Real user code shouldn't need this — the patterns it suppresses are bug-shaped in production. The directive exists so we can write tests that verify q.Open's deferred close actually fires.
See also¶
- Design — why
.DeferCleanupis terminal